From 270bdaac93aba605710ce1c870a47ec3d0abdb09 Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:44:06 +0500 Subject: [PATCH 1/8] Restore 1.x API compatibility --- ARCHITECTURE.md | 16 +++ README.md | 4 + src/ReCaptcha/ReCaptcha.php | 88 +++++++++++---- src/ReCaptcha/RequestMethod.php | 4 +- src/ReCaptcha/RequestMethod/Curl.php | 86 +++++++++++++++ src/ReCaptcha/RequestMethod/CurlPost.php | 26 +++-- src/ReCaptcha/RequestMethod/Post.php | 6 +- src/ReCaptcha/RequestMethod/Socket.php | 100 ++++++++++++++++++ src/ReCaptcha/RequestMethod/SocketPost.php | 30 ++++-- src/ReCaptcha/RequestParameters.php | 53 +++++++--- src/ReCaptcha/Response.php | 76 +++++++++---- tests/ReCaptcha/ReCaptchaTest.php | 58 +++++++++- .../ReCaptcha/RequestMethod/CurlPostTest.php | 10 ++ .../RequestMethod/SocketPostTest.php | 21 ++++ tests/ReCaptcha/RequestParametersTest.php | 7 ++ tests/ReCaptcha/ResponseTest.php | 15 +++ 16 files changed, 522 insertions(+), 78 deletions(-) create mode 100644 src/ReCaptcha/RequestMethod/Curl.php create mode 100644 src/ReCaptcha/RequestMethod/Socket.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 794ba72..b077203 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,3 +38,19 @@ with additional error codes based on the client's checks. When adding a new [`RequestMethod`](./src/ReCaptcha/RequestMethod.php) ensure that it returns the `ReCaptcha::E_CONNECTION_FAILED` and `ReCaptcha::E_BAD_RESPONSE` where appropriate. + +## Public API compatibility + +The 1.x line treats the following classes and interfaces as public API: +`ReCaptcha`, `RequestMethod`, `Response`, `RequestParameters`, +`RequestMethod\Post`, `RequestMethod\CurlPost`, and +`RequestMethod\SocketPost`. + +Changes that narrow those APIs, such as adding native scalar parameter types, +adding native return types to existing public methods, making public non-final +classes `readonly` or `final`, removing public classes, or removing existing +constructor argument forms, should be reserved for a major release. + +The `RequestMethod::submit()` interface intentionally keeps its 1.x-compatible +native signature. Implementations are still expected to return the body of the +reCAPTCHA response as a string. diff --git a/README.md b/README.md index 55f77ee..599cd3a 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ $recaptcha = new \ReCaptcha\ReCaptcha($secret, new \ReCaptcha\RequestMethod\Sock For more details on usage and structure, see [ARCHITECTURE](ARCHITECTURE.md). +The 1.x line preserves compatibility for the public request and response APIs. +See [Public API compatibility](ARCHITECTURE.md#public-api-compatibility) for +details on which API changes require a major release. + ### Examples You can see examples of each reCAPTCHA type in [examples/](examples/). You can diff --git a/src/ReCaptcha/ReCaptcha.php b/src/ReCaptcha/ReCaptcha.php index f96bf9b..01a445f 100644 --- a/src/ReCaptcha/ReCaptcha.php +++ b/src/ReCaptcha/ReCaptcha.php @@ -154,13 +154,17 @@ class ReCaptcha /** * Create a configured instance to use the reCAPTCHA service. * - * @param string $secret the shared key between your site and reCAPTCHA + * @param mixed $secret the shared key between your site and reCAPTCHA * @param RequestMethod $requestMethod method used to send the request. Defaults to POST. * * @throws \RuntimeException if $secret is invalid */ - public function __construct(string $secret, ?RequestMethod $requestMethod = null) + public function __construct($secret, ?RequestMethod $requestMethod = null) { + if (!is_string($secret)) { + throw new \RuntimeException('The provided secret must be a string'); + } + if ('' === $secret) { throw new \RuntimeException('No secret provided'); } @@ -180,20 +184,26 @@ public function __construct(string $secret, ?RequestMethod $requestMethod = null * Calls the reCAPTCHA siteverify API to verify whether the user passes * CAPTCHA test and additionally runs any specified additional checks. * - * @param string $response the user response token provided by reCAPTCHA, verifying the user on your site - * @param null|string $remoteIp the end user's IP address + * @param mixed $response the user response token provided by reCAPTCHA, verifying the user on your site + * @param mixed $remoteIp the end user's IP address * * @return Response response from the service */ - public function verify(string $response, ?string $remoteIp = null): Response + public function verify($response, $remoteIp = null) { // Discard empty solution submissions - if ('' === $response) { + if (!is_string($response) || '' === $response) { return new Response(false, [self::E_MISSING_INPUT_RESPONSE]); } + $remoteIp = self::nullableStringValue($remoteIp); $params = new RequestParameters($this->secret, $response, $remoteIp, self::VERSION); $rawResponse = $this->requestMethod->submit($params); + + if (!is_string($rawResponse)) { + return new Response(false, [self::E_BAD_RESPONSE]); + } + $initialResponse = Response::fromJson($rawResponse); $validationErrors = []; @@ -240,13 +250,13 @@ public function verify(string $response, ?string $remoteIp = null): Response * Provide a hostname to match against in verify() * This should be without a protocol or trailing slash, e.g. www.google.com. * - * @param string $hostname Expected hostname + * @param mixed $hostname Expected hostname * * @return ReCaptcha Current instance for fluent interface */ - public function setExpectedHostname(string $hostname): self + public function setExpectedHostname($hostname) { - $this->hostname = $hostname; + $this->hostname = self::stringValue($hostname); return $this; } @@ -254,13 +264,13 @@ public function setExpectedHostname(string $hostname): self /** * Provide an APK package name to match against in verify(). * - * @param string $apkPackageName Expected APK package name + * @param mixed $apkPackageName Expected APK package name * * @return ReCaptcha Current instance for fluent interface */ - public function setExpectedApkPackageName(string $apkPackageName): self + public function setExpectedApkPackageName($apkPackageName) { - $this->apkPackageName = $apkPackageName; + $this->apkPackageName = self::stringValue($apkPackageName); return $this; } @@ -269,13 +279,13 @@ public function setExpectedApkPackageName(string $apkPackageName): self * Provide an action to match against in verify() * This should be set per page. * - * @param string $action Expected action + * @param mixed $action Expected action * * @return ReCaptcha Current instance for fluent interface */ - public function setExpectedAction(string $action): self + public function setExpectedAction($action) { - $this->action = $action; + $this->action = self::stringValue($action); return $this; } @@ -284,13 +294,13 @@ public function setExpectedAction(string $action): self * Provide a threshold to meet or exceed in verify() * Threshold should be a float between 0 and 1 which will be tested as response >= threshold. * - * @param float $threshold Expected threshold + * @param mixed $threshold Expected threshold * * @return ReCaptcha Current instance for fluent interface */ - public function setScoreThreshold(float $threshold): self + public function setScoreThreshold($threshold) { - $this->threshold = $threshold; + $this->threshold = self::floatValue($threshold); return $this; } @@ -298,14 +308,50 @@ public function setScoreThreshold(float $threshold): self /** * Provide a timeout in seconds to test against the challenge timestamp in verify(). * - * @param int $timeoutSeconds Maximum time (seconds) elapsed since the challenge timestamp + * @param mixed $timeoutSeconds Maximum time (seconds) elapsed since the challenge timestamp * * @return ReCaptcha Current instance for fluent interface */ - public function setChallengeTimeout(int $timeoutSeconds): self + public function setChallengeTimeout($timeoutSeconds) { - $this->timeoutSeconds = $timeoutSeconds; + $this->timeoutSeconds = self::intValue($timeoutSeconds); return $this; } + + private static function nullableStringValue(mixed $value): ?string + { + if (is_null($value)) { + return null; + } + + return self::stringValue($value); + } + + private static function stringValue(mixed $value): string + { + if (is_scalar($value) || $value instanceof \Stringable) { + return (string) $value; + } + + return ''; + } + + private static function floatValue(mixed $value): float + { + if (is_null($value) || is_scalar($value)) { + return floatval($value); + } + + return 0.0; + } + + private static function intValue(mixed $value): int + { + if (is_null($value) || is_scalar($value)) { + return intval($value); + } + + return 0; + } } diff --git a/src/ReCaptcha/RequestMethod.php b/src/ReCaptcha/RequestMethod.php index f39234a..1255787 100644 --- a/src/ReCaptcha/RequestMethod.php +++ b/src/ReCaptcha/RequestMethod.php @@ -49,7 +49,7 @@ interface RequestMethod * * @param RequestParameters $params Request parameters * - * @return string Body of the reCAPTCHA response + * @return mixed Body of the reCAPTCHA response */ - public function submit(RequestParameters $params): string; + public function submit(RequestParameters $params); } diff --git a/src/ReCaptcha/RequestMethod/Curl.php b/src/ReCaptcha/RequestMethod/Curl.php new file mode 100644 index 0000000..42bf2f6 --- /dev/null +++ b/src/ReCaptcha/RequestMethod/Curl.php @@ -0,0 +1,86 @@ + $options + */ + public function setoptArray($ch, array $options): bool + { + // @phpstan-ignore argument.type + return curl_setopt_array($ch, $options); + } + + /** + * @param mixed $ch + * + * @return mixed + */ + public function exec($ch) + { + // @phpstan-ignore argument.type + return curl_exec($ch); + } + + /** + * @param mixed $ch + */ + public function close($ch): void + { + // @phpstan-ignore argument.type + curl_close($ch); + } +} diff --git a/src/ReCaptcha/RequestMethod/CurlPost.php b/src/ReCaptcha/RequestMethod/CurlPost.php index 5ff831a..7ffaf2d 100644 --- a/src/ReCaptcha/RequestMethod/CurlPost.php +++ b/src/ReCaptcha/RequestMethod/CurlPost.php @@ -51,6 +51,8 @@ */ class CurlPost implements RequestMethod { + private Curl $curl; + /** * URL for reCAPTCHA siteverify API. */ @@ -59,11 +61,19 @@ class CurlPost implements RequestMethod /** * Only needed if you want to override the defaults. * - * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API + * @param null|Curl|string $curlOrSiteVerifyUrl Curl wrapper or URL for reCAPTCHA siteverify API + * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API */ - public function __construct(?string $siteVerifyUrl = null) + public function __construct($curlOrSiteVerifyUrl = null, $siteVerifyUrl = null) { - $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + if ($curlOrSiteVerifyUrl instanceof Curl) { + $this->curl = $curlOrSiteVerifyUrl; + } else { + $this->curl = new Curl(); + $siteVerifyUrl = $curlOrSiteVerifyUrl ?? $siteVerifyUrl; + } + + $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : (string) $siteVerifyUrl; } /** @@ -73,9 +83,9 @@ public function __construct(?string $siteVerifyUrl = null) * * @return string Body of the reCAPTCHA response */ - public function submit(RequestParameters $params): string + public function submit(RequestParameters $params) { - $handle = curl_init($this->siteVerifyUrl); + $handle = $this->curl->init($this->siteVerifyUrl); $options = [ CURLOPT_POST => true, @@ -89,10 +99,10 @@ public function submit(RequestParameters $params): string CURLOPT_SSL_VERIFYPEER => true, CURLOPT_TIMEOUT => 60, ]; - curl_setopt_array($handle, $options); + $this->curl->setoptArray($handle, $options); try { - $response = curl_exec($handle); + $response = $this->curl->exec($handle); if (is_string($response)) { return $response; @@ -100,7 +110,7 @@ public function submit(RequestParameters $params): string return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; } finally { - curl_close($handle); + $this->curl->close($handle); } } } diff --git a/src/ReCaptcha/RequestMethod/Post.php b/src/ReCaptcha/RequestMethod/Post.php index 6d2c04f..48ae65a 100644 --- a/src/ReCaptcha/RequestMethod/Post.php +++ b/src/ReCaptcha/RequestMethod/Post.php @@ -58,9 +58,9 @@ class Post implements RequestMethod * * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API */ - public function __construct(?string $siteVerifyUrl = null) + public function __construct($siteVerifyUrl = null) { - $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : (string) $siteVerifyUrl; } /** @@ -70,7 +70,7 @@ public function __construct(?string $siteVerifyUrl = null) * * @return string Body of the reCAPTCHA response */ - public function submit(RequestParameters $params): string + public function submit(RequestParameters $params) { $options = [ 'ssl' => [ diff --git a/src/ReCaptcha/RequestMethod/Socket.php b/src/ReCaptcha/RequestMethod/Socket.php new file mode 100644 index 0000000..77ea414 --- /dev/null +++ b/src/ReCaptcha/RequestMethod/Socket.php @@ -0,0 +1,100 @@ +handle = fsockopen($hostname, $port, $errno, $errstr, $timeout); + + if (false !== $this->handle && 0 === $errno && '' === $errstr) { + return $this->handle; + } + + if (false !== $this->handle) { + $this->fclose(); + } + + return false; + } + + public function streamSetTimeout(int $seconds): bool + { + // @phpstan-ignore argument.type + return stream_set_timeout($this->handle, $seconds); + } + + public function fwrite(string $string): false|int + { + // @phpstan-ignore argument.type + return fwrite($this->handle, $string); + } + + public function streamGetContents(): false|string + { + // @phpstan-ignore argument.type + return stream_get_contents($this->handle); + } + + public function fclose(): bool + { + // @phpstan-ignore argument.type + return fclose($this->handle); + } +} diff --git a/src/ReCaptcha/RequestMethod/SocketPost.php b/src/ReCaptcha/RequestMethod/SocketPost.php index 955a49d..93fd199 100644 --- a/src/ReCaptcha/RequestMethod/SocketPost.php +++ b/src/ReCaptcha/RequestMethod/SocketPost.php @@ -50,16 +50,26 @@ */ class SocketPost implements RequestMethod { + private Socket $socket; + private string $siteVerifyUrl; /** * Only needed if you want to override the defaults. * - * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API + * @param null|Socket|string $socketOrSiteVerifyUrl Socket wrapper or URL for reCAPTCHA siteverify API + * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API */ - public function __construct(?string $siteVerifyUrl = null) + public function __construct($socketOrSiteVerifyUrl = null, $siteVerifyUrl = null) { - $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + if ($socketOrSiteVerifyUrl instanceof Socket) { + $this->socket = $socketOrSiteVerifyUrl; + } else { + $this->socket = new Socket(); + $siteVerifyUrl = $socketOrSiteVerifyUrl ?? $siteVerifyUrl; + } + + $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : (string) $siteVerifyUrl; } /** @@ -69,7 +79,7 @@ public function __construct(?string $siteVerifyUrl = null) * * @return string Body of the reCAPTCHA response */ - public function submit(RequestParameters $params): string + public function submit(RequestParameters $params) { $errno = 0; $errstr = ''; @@ -79,13 +89,15 @@ public function submit(RequestParameters $params): string return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; } - $handle = fsockopen('ssl://'.$urlParsed['host'], 443, $errno, $errstr, 30); + $handle = $this->socket->fsockopen('ssl://'.$urlParsed['host'], 443, $errno, $errstr, 30); if (false === $handle || 0 !== $errno || '' !== $errstr) { return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; } - if (false === stream_set_timeout($handle, 60)) { + if (false === $this->socket->streamSetTimeout(60)) { + $this->socket->fclose(); + return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; } @@ -98,10 +110,10 @@ public function submit(RequestParameters $params): string $request .= "Connection: close\r\n\r\n"; $request .= $content."\r\n\r\n"; - fwrite($handle, $request); - $response = stream_get_contents($handle); + $this->socket->fwrite($request); + $response = $this->socket->streamGetContents(); - fclose($handle); + $this->socket->fclose(); if (!is_string($response)) { $response = ''; diff --git a/src/ReCaptcha/RequestParameters.php b/src/ReCaptcha/RequestParameters.php index fd20b14..089c498 100644 --- a/src/ReCaptcha/RequestParameters.php +++ b/src/ReCaptcha/RequestParameters.php @@ -42,29 +42,38 @@ /** * Stores and formats the parameters for the request to the reCAPTCHA service. */ -readonly class RequestParameters +class RequestParameters { + private string $secret; + + private string $response; + + private ?string $remoteIp; + + private ?string $version; + /** * Initialise parameters. * - * @param string $secret site secret - * @param string $response value from g-captcha-response form field - * @param null|string $remoteIp user's IP address - * @param null|string $version version of this client library + * @param mixed $secret site secret + * @param mixed $response value from g-captcha-response form field + * @param mixed $remoteIp user's IP address + * @param mixed $version version of this client library */ - public function __construct( - private string $secret, - private string $response, - private ?string $remoteIp = null, - private ?string $version = null, - ) {} + public function __construct($secret, $response, $remoteIp = null, $version = null) + { + $this->secret = self::stringValue($secret); + $this->response = self::stringValue($response); + $this->remoteIp = self::nullableStringValue($remoteIp); + $this->version = self::nullableStringValue($version); + } /** * Array representation. * * @return array array formatted parameters */ - public function toArray(): array + public function toArray() { $params = ['secret' => $this->secret, 'response' => $this->response]; @@ -84,8 +93,26 @@ public function toArray(): array * * @return string query string formatted parameters */ - public function toQueryString(): string + public function toQueryString() { return http_build_query($this->toArray(), '', '&'); } + + private static function nullableStringValue(mixed $value): ?string + { + if (is_null($value)) { + return null; + } + + return self::stringValue($value); + } + + private static function stringValue(mixed $value): string + { + if (is_scalar($value) || $value instanceof \Stringable) { + return (string) $value; + } + + return ''; + } } diff --git a/src/ReCaptcha/Response.php b/src/ReCaptcha/Response.php index 7cdf9e0..7ed45e2 100644 --- a/src/ReCaptcha/Response.php +++ b/src/ReCaptcha/Response.php @@ -42,8 +42,25 @@ /** * The response returned from the service. */ -readonly class Response +class Response { + private bool $success = false; + + /** + * @var array + */ + private array $errorCodes = []; + + private string $hostname = ''; + + private string $challengeTs = ''; + + private string $apkPackageName = ''; + + private ?float $score = null; + + private string $action = ''; + /** * Constructor. * @@ -55,21 +72,30 @@ * @param ?float $score score assigned to the request * @param string $action action as specified by the page */ - public function __construct( - private bool $success, - private array $errorCodes = [], - private string $hostname = '', - private string $challengeTs = '', - private string $apkPackageName = '', - private ?float $score = null, - private string $action = '', - ) {} + public function __construct($success, array $errorCodes = [], $hostname = '', $challengeTs = '', $apkPackageName = '', $score = null, $action = '') + { + $this->success = (bool) $success; + $this->errorCodes = $errorCodes; + $this->hostname = (string) $hostname; + $this->challengeTs = (string) $challengeTs; + $this->apkPackageName = (string) $apkPackageName; + $this->score = is_null($score) ? null : floatval($score); + $this->action = (string) $action; + } /** * Build the response from the expected JSON returned by the service. + * + * @param mixed $json + * + * @return Response */ - public static function fromJson(string $json): Response + public static function fromJson($json) { + if (!is_string($json)) { + return new Response(false, [ReCaptcha::E_INVALID_JSON]); + } + $responseData = json_decode($json, true); if (!is_array($responseData)) { @@ -98,8 +124,10 @@ public static function fromJson(string $json): Response /** * Is success? + * + * @return bool */ - public function isSuccess(): bool + public function isSuccess() { return $this->success; } @@ -109,47 +137,57 @@ public function isSuccess(): bool * * @return array */ - public function getErrorCodes(): array + public function getErrorCodes() { return $this->errorCodes; } /** * Get hostname. + * + * @return string */ - public function getHostname(): string + public function getHostname() { return $this->hostname; } /** * Get challenge timestamp. + * + * @return string */ - public function getChallengeTs(): string + public function getChallengeTs() { return $this->challengeTs; } /** * Get APK package name. + * + * @return string */ - public function getApkPackageName(): string + public function getApkPackageName() { return $this->apkPackageName; } /** * Get score. + * + * @return null|float */ - public function getScore(): ?float + public function getScore() { return $this->score; } /** * Get action. + * + * @return string */ - public function getAction(): string + public function getAction() { return $this->action; } @@ -167,7 +205,7 @@ public function getAction(): string * error-codes: string[] * } */ - public function toArray(): array + public function toArray() { return [ 'success' => $this->isSuccess(), diff --git a/tests/ReCaptcha/ReCaptchaTest.php b/tests/ReCaptcha/ReCaptchaTest.php index 57f6e25..030efa6 100644 --- a/tests/ReCaptcha/ReCaptchaTest.php +++ b/tests/ReCaptcha/ReCaptchaTest.php @@ -77,9 +77,8 @@ protected function tearDown(): void #[DataProvider('invalidSecretProvider')] public function testExceptionThrownOnInvalidSecretType(mixed $invalid): void { - $this->expectException(\TypeError::class); + $this->expectException(\RuntimeException::class); - /** @phpstan-ignore argument.type */ $rc = new ReCaptcha($invalid); } @@ -101,7 +100,6 @@ public function testExceptionThrownOnEmptySecret(mixed $emptySecret): void { $this->expectException(\RuntimeException::class); - /** @phpstan-ignore argument.type */ $rc = new ReCaptcha($emptySecret); } @@ -123,6 +121,52 @@ public function testVerifyReturnsErrorOnMissingResponse(): void $this->assertEquals([ReCaptcha::E_MISSING_INPUT_RESPONSE], $response->getErrorCodes()); } + public function testVerifyReturnsErrorOnNullResponse(): void + { + $rc = new ReCaptcha('secret'); + $response = $rc->verify(null); + $this->assertFalse($response->isSuccess()); + $this->assertEquals([ReCaptcha::E_MISSING_INPUT_RESPONSE], $response->getErrorCodes()); + } + + public function testRequestMethodSubmitKeepsLegacyReturnTypeCompatibility(): void + { + $submit = new \ReflectionMethod(RequestMethod::class, 'submit'); + + $this->assertFalse($submit->hasReturnType()); + } + + public function testLegacyRequestMethodImplementationWithoutReturnTypeCanBeUsed(): void + { + $method = new class implements RequestMethod { + public function submit(RequestParameters $params) + { + return '{"success": true}'; + } + }; + + $rc = new ReCaptcha('secret', $method); + $response = $rc->verify('response'); + + $this->assertTrue($response->isSuccess()); + } + + public function testNonStringRequestMethodResponseReturnsBadResponse(): void + { + $method = new class implements RequestMethod { + public function submit(RequestParameters $params) + { + return false; + } + }; + + $rc = new ReCaptcha('secret', $method); + $response = $rc->verify('response'); + + $this->assertFalse($response->isSuccess()); + $this->assertEquals([ReCaptcha::E_BAD_RESPONSE], $response->getErrorCodes()); + } + public function testZeroAsStringIsValidSecret(): void { $rc = new ReCaptcha('0'); @@ -256,6 +300,14 @@ public function testVerifyBelowThreshold(): void $this->assertEquals([ReCaptcha::E_SCORE_THRESHOLD_NOT_MET], $response->getErrorCodes()); } + public function testScoreThresholdAcceptsNumericString(): void + { + $method = $this->getMockRequestMethod('{"success": true, "score": "0.9"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setScoreThreshold('0.5')->verify('response'); + $this->assertTrue($response->isSuccess()); + } + public function testVerifyWithinTimeout(): void { // Responses come back like 2018-07-31T13:48:41Z diff --git a/tests/ReCaptcha/RequestMethod/CurlPostTest.php b/tests/ReCaptcha/RequestMethod/CurlPostTest.php index 88e4d39..539d0c1 100644 --- a/tests/ReCaptcha/RequestMethod/CurlPostTest.php +++ b/tests/ReCaptcha/RequestMethod/CurlPostTest.php @@ -133,6 +133,16 @@ public function testOverrideSiteVerifyUrl(): void $this->assertEquals('RESPONSEBODY', $response); } + public function testLegacyConstructorOverrideSiteVerifyUrl(): void + { + $url = 'OVERRIDE'; + $pc = new CurlPost(null, $url); + $response = $pc->submit(new RequestParameters('secret', 'response')); + + $this->assertEquals($url, CurlPostGlobalState::$initUrl); + $this->assertEquals('RESPONSEBODY', $response); + } + public function testConnectionFailureReturnsError(): void { CurlPostGlobalState::$execResponse = false; diff --git a/tests/ReCaptcha/RequestMethod/SocketPostTest.php b/tests/ReCaptcha/RequestMethod/SocketPostTest.php index ec6193e..06f3a39 100644 --- a/tests/ReCaptcha/RequestMethod/SocketPostTest.php +++ b/tests/ReCaptcha/RequestMethod/SocketPostTest.php @@ -182,6 +182,25 @@ public function testOverrideSiteVerifyUrl(): void $this->assertTrue(SocketPostGlobalState::$fcloseCalled); } + public function testLegacyConstructorOverrideSiteVerifyUrl(): void + { + SocketPostGlobalState::$fgetsResponses = [ + "HTTP/1.0 200 OK\r\n", + "Content-Type: application/json\r\n", + "\r\n", + 'RESPONSEBODY', + ]; + + $url = 'https://custom.recaptcha.net/recaptcha/api/siteverify'; + $sp = new SocketPost(null, $url); + $response = $sp->submit(new RequestParameters('secret', 'response')); + + $this->assertEquals('ssl://custom.recaptcha.net', SocketPostGlobalState::$fsockopenHostname); + $this->assertStringContainsString('POST /recaptcha/api/siteverify HTTP/1.0', SocketPostGlobalState::$fwriteData); + $this->assertEquals('RESPONSEBODY', $response); + $this->assertTrue(SocketPostGlobalState::$fcloseCalled); + } + public function testSubmitReturnsResponseWhenHttp11(): void { SocketPostGlobalState::$fgetsResponses = [ @@ -205,6 +224,7 @@ public function testStreamTimeoutFailureReturnsError(): void $response = $sp->submit(new RequestParameters('secret', 'response')); $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response); + $this->assertTrue(SocketPostGlobalState::$fcloseCalled); } public function testConnectionFailureWithValidHandleReturnsError(): void @@ -216,6 +236,7 @@ public function testConnectionFailureWithValidHandleReturnsError(): void $response = $sp->submit(new RequestParameters('secret', 'response')); $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response); + $this->assertTrue(SocketPostGlobalState::$fcloseCalled); } public function testUrlFailureReturnsError(): void diff --git a/tests/ReCaptcha/RequestParametersTest.php b/tests/ReCaptcha/RequestParametersTest.php index 8b5bcdc..194bbe9 100644 --- a/tests/ReCaptcha/RequestParametersTest.php +++ b/tests/ReCaptcha/RequestParametersTest.php @@ -87,4 +87,11 @@ public static function provideValidData(): array ], ]; } + + public function testClassRemainsExtendable(): void + { + $class = new \ReflectionClass(RequestParameters::class); + + $this->assertFalse($class->isReadOnly()); + } } diff --git a/tests/ReCaptcha/ResponseTest.php b/tests/ReCaptcha/ResponseTest.php index c18a8a4..d95cf70 100644 --- a/tests/ReCaptcha/ResponseTest.php +++ b/tests/ReCaptcha/ResponseTest.php @@ -126,6 +126,21 @@ public static function provideJson(): array ]; } + public function testFromJsonReturnsInvalidJsonForNull(): void + { + $response = Response::fromJson(null); + + $this->assertFalse($response->isSuccess()); + $this->assertEquals([ReCaptcha::E_INVALID_JSON], $response->getErrorCodes()); + } + + public function testClassRemainsExtendable(): void + { + $class = new \ReflectionClass(Response::class); + + $this->assertFalse($class->isReadOnly()); + } + public function testIsSuccess(): void { $response = new Response(true); From 2d3bf3efe2be31b7a47c71a247d3b45c477f787e Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:31:05 +0500 Subject: [PATCH 2/8] fix:audit-test --- ARCHITECTURE.md | 3 +- composer.json | 2 +- src/ReCaptcha/ReCaptcha.php | 4 +- src/ReCaptcha/RequestMethod/Curl.php | 8 +- src/ReCaptcha/RequestMethod/CurlPost.php | 12 +-- src/ReCaptcha/RequestMethod/Socket.php | 73 ++++++++++++++++--- src/ReCaptcha/RequestMethod/SocketPost.php | 12 +-- src/ReCaptcha/Response.php | 46 +++++++++--- tests/ReCaptcha/ReCaptchaTest.php | 8 ++ .../ReCaptcha/RequestMethod/CurlPostTest.php | 19 +++++ .../RequestMethod/SocketPostTest.php | 27 +++++++ tests/ReCaptcha/ResponseTest.php | 12 +++ 12 files changed, 188 insertions(+), 38 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b077203..4e794e5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -44,7 +44,8 @@ appropriate. The 1.x line treats the following classes and interfaces as public API: `ReCaptcha`, `RequestMethod`, `Response`, `RequestParameters`, `RequestMethod\Post`, `RequestMethod\CurlPost`, and -`RequestMethod\SocketPost`. +`RequestMethod\SocketPost`, plus the request wrapper classes +`RequestMethod\Curl` and `RequestMethod\Socket`. Changes that narrow those APIs, such as adding native scalar parameter types, adding native return types to existing public methods, making public non-final diff --git a/composer.json b/composer.json index 0639a0d..a9ec6e8 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "lint": "vendor/bin/php-cs-fixer -vvv check --using-cache=no", "lint-fix": "vendor/bin/php-cs-fixer -vvv fix --using-cache=no", "phpstan": "vendor/bin/phpstan", - "test": "XDEBUG_MODE=coverage vendor/bin/phpunit", + "test": "@php -d xdebug.mode=coverage vendor/bin/phpunit", "serve-examples": "@php -S localhost:8080 -t examples" }, "config": { diff --git a/src/ReCaptcha/ReCaptcha.php b/src/ReCaptcha/ReCaptcha.php index 01a445f..3a8d099 100644 --- a/src/ReCaptcha/ReCaptcha.php +++ b/src/ReCaptcha/ReCaptcha.php @@ -191,8 +191,10 @@ public function __construct($secret, ?RequestMethod $requestMethod = null) */ public function verify($response, $remoteIp = null) { + $response = self::stringValue($response); + // Discard empty solution submissions - if (!is_string($response) || '' === $response) { + if ('' === $response) { return new Response(false, [self::E_MISSING_INPUT_RESPONSE]); } diff --git a/src/ReCaptcha/RequestMethod/Curl.php b/src/ReCaptcha/RequestMethod/Curl.php index 42bf2f6..b65cc49 100644 --- a/src/ReCaptcha/RequestMethod/Curl.php +++ b/src/ReCaptcha/RequestMethod/Curl.php @@ -57,8 +57,10 @@ public function init($url = null) /** * @param mixed $ch * @param array $options + * + * @return bool */ - public function setoptArray($ch, array $options): bool + public function setoptArray($ch, array $options) { // @phpstan-ignore argument.type return curl_setopt_array($ch, $options); @@ -77,8 +79,10 @@ public function exec($ch) /** * @param mixed $ch + * + * @phpstan-return void */ - public function close($ch): void + public function close($ch) { // @phpstan-ignore argument.type curl_close($ch); diff --git a/src/ReCaptcha/RequestMethod/CurlPost.php b/src/ReCaptcha/RequestMethod/CurlPost.php index 7ffaf2d..d9ccaea 100644 --- a/src/ReCaptcha/RequestMethod/CurlPost.php +++ b/src/ReCaptcha/RequestMethod/CurlPost.php @@ -61,16 +61,16 @@ class CurlPost implements RequestMethod /** * Only needed if you want to override the defaults. * - * @param null|Curl|string $curlOrSiteVerifyUrl Curl wrapper or URL for reCAPTCHA siteverify API - * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API + * @param null|Curl|string $curl Curl wrapper or URL for reCAPTCHA siteverify API + * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API */ - public function __construct($curlOrSiteVerifyUrl = null, $siteVerifyUrl = null) + public function __construct($curl = null, $siteVerifyUrl = null) { - if ($curlOrSiteVerifyUrl instanceof Curl) { - $this->curl = $curlOrSiteVerifyUrl; + if ($curl instanceof Curl) { + $this->curl = $curl; } else { $this->curl = new Curl(); - $siteVerifyUrl = $curlOrSiteVerifyUrl ?? $siteVerifyUrl; + $siteVerifyUrl = $curl ?? $siteVerifyUrl; } $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : (string) $siteVerifyUrl; diff --git a/src/ReCaptcha/RequestMethod/Socket.php b/src/ReCaptcha/RequestMethod/Socket.php index 77ea414..c40bbea 100644 --- a/src/ReCaptcha/RequestMethod/Socket.php +++ b/src/ReCaptcha/RequestMethod/Socket.php @@ -60,10 +60,10 @@ class Socket */ public function fsockopen($hostname, $port = -1, &$errno = 0, &$errstr = '', $timeout = null) { - $timeout = is_null($timeout) ? floatval(ini_get('default_socket_timeout')) : $timeout; + $timeout = is_null($timeout) ? floatval(ini_get('default_socket_timeout')) : floatval($timeout); $this->handle = fsockopen($hostname, $port, $errno, $errstr, $timeout); - if (false !== $this->handle && 0 === $errno && '' === $errstr) { + if (false != $this->handle && 0 === $errno && '' === $errstr) { return $this->handle; } @@ -74,25 +74,80 @@ public function fsockopen($hostname, $port = -1, &$errno = 0, &$errstr = '', $ti return false; } - public function streamSetTimeout(int $seconds): bool + /** + * @param int $seconds + * @param int $microseconds + * + * @return bool + */ + public function streamSetTimeout($seconds, $microseconds = 0) + { + // @phpstan-ignore argument.type + return stream_set_timeout($this->handle, $seconds, $microseconds); + } + + /** + * @param string $string + * @param null|int $length + * + * @return false|int + */ + public function fwrite($string, $length = null) + { + $string = (string) $string; + + // @phpstan-ignore argument.type + return fwrite($this->handle, $string, is_null($length) ? strlen($string) : $length); + } + + /** + * @param null|int $length + * @param int $offset + * + * @return false|string + */ + public function streamGetContents($length = null, $offset = -1) { + if (is_null($length)) { + // @phpstan-ignore argument.type + return stream_get_contents($this->handle); + } + // @phpstan-ignore argument.type - return stream_set_timeout($this->handle, $seconds); + return stream_get_contents($this->handle, $length, $offset); } - public function fwrite(string $string): false|int + /** + * @param null|int $length + * + * @return false|string + */ + public function fgets($length = null) { + if (is_null($length)) { + // @phpstan-ignore argument.type + return fgets($this->handle); + } + + $length = max(0, intval($length)); + // @phpstan-ignore argument.type - return fwrite($this->handle, $string); + return fgets($this->handle, $length); } - public function streamGetContents(): false|string + /** + * @return bool + */ + public function feof() { // @phpstan-ignore argument.type - return stream_get_contents($this->handle); + return feof($this->handle); } - public function fclose(): bool + /** + * @return bool + */ + public function fclose() { // @phpstan-ignore argument.type return fclose($this->handle); diff --git a/src/ReCaptcha/RequestMethod/SocketPost.php b/src/ReCaptcha/RequestMethod/SocketPost.php index 93fd199..e5fdba4 100644 --- a/src/ReCaptcha/RequestMethod/SocketPost.php +++ b/src/ReCaptcha/RequestMethod/SocketPost.php @@ -57,16 +57,16 @@ class SocketPost implements RequestMethod /** * Only needed if you want to override the defaults. * - * @param null|Socket|string $socketOrSiteVerifyUrl Socket wrapper or URL for reCAPTCHA siteverify API - * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API + * @param null|Socket|string $socket Socket wrapper or URL for reCAPTCHA siteverify API + * @param null|string $siteVerifyUrl URL for reCAPTCHA siteverify API */ - public function __construct($socketOrSiteVerifyUrl = null, $siteVerifyUrl = null) + public function __construct($socket = null, $siteVerifyUrl = null) { - if ($socketOrSiteVerifyUrl instanceof Socket) { - $this->socket = $socketOrSiteVerifyUrl; + if ($socket instanceof Socket) { + $this->socket = $socket; } else { $this->socket = new Socket(); - $siteVerifyUrl = $socketOrSiteVerifyUrl ?? $siteVerifyUrl; + $siteVerifyUrl = $socket ?? $siteVerifyUrl; } $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : (string) $siteVerifyUrl; diff --git a/src/ReCaptcha/Response.php b/src/ReCaptcha/Response.php index 7ed45e2..a927d32 100644 --- a/src/ReCaptcha/Response.php +++ b/src/ReCaptcha/Response.php @@ -76,11 +76,11 @@ public function __construct($success, array $errorCodes = [], $hostname = '', $c { $this->success = (bool) $success; $this->errorCodes = $errorCodes; - $this->hostname = (string) $hostname; - $this->challengeTs = (string) $challengeTs; - $this->apkPackageName = (string) $apkPackageName; - $this->score = is_null($score) ? null : floatval($score); - $this->action = (string) $action; + $this->hostname = self::stringValue($hostname); + $this->challengeTs = self::stringValue($challengeTs); + $this->apkPackageName = self::stringValue($apkPackageName); + $this->score = self::nullableFloatValue($score); + $this->action = self::stringValue($action); } /** @@ -98,17 +98,17 @@ public static function fromJson($json) $responseData = json_decode($json, true); - if (!is_array($responseData)) { + if (!$responseData || !is_array($responseData)) { return new Response(false, [ReCaptcha::E_INVALID_JSON]); } - $hostname = isset($responseData['hostname']) && is_string($responseData['hostname']) ? $responseData['hostname'] : ''; - $challengeTs = isset($responseData['challenge_ts']) && is_string($responseData['challenge_ts']) ? $responseData['challenge_ts'] : ''; - $apkPackageName = isset($responseData['apk_package_name']) && is_string($responseData['apk_package_name']) ? $responseData['apk_package_name'] : ''; - $score = isset($responseData['score']) && is_numeric($responseData['score']) ? floatval($responseData['score']) : null; - $action = isset($responseData['action']) && is_string($responseData['action']) ? $responseData['action'] : ''; + $hostname = self::stringValue($responseData['hostname'] ?? ''); + $challengeTs = self::stringValue($responseData['challenge_ts'] ?? ''); + $apkPackageName = self::stringValue($responseData['apk_package_name'] ?? ''); + $score = self::nullableFloatValue($responseData['score'] ?? null); + $action = self::stringValue($responseData['action'] ?? ''); - if (isset($responseData['success']) && true === $responseData['success']) { + if (isset($responseData['success']) && true == $responseData['success']) { return new Response(true, [], $hostname, $challengeTs, $apkPackageName, $score, $action); } @@ -217,4 +217,26 @@ public function toArray() 'error-codes' => $this->getErrorCodes(), ]; } + + private static function stringValue(mixed $value): string + { + if (is_scalar($value) || $value instanceof \Stringable) { + return (string) $value; + } + + return ''; + } + + private static function nullableFloatValue(mixed $value): ?float + { + if (is_null($value)) { + return null; + } + + if (is_scalar($value)) { + return floatval($value); + } + + return null; + } } diff --git a/tests/ReCaptcha/ReCaptchaTest.php b/tests/ReCaptcha/ReCaptchaTest.php index 030efa6..0e15ba0 100644 --- a/tests/ReCaptcha/ReCaptchaTest.php +++ b/tests/ReCaptcha/ReCaptchaTest.php @@ -181,6 +181,14 @@ public function testZeroAsStringIsValidResponse(): void $this->assertTrue($response->isSuccess()); } + public function testScalarResponseIsAccepted(): void + { + $method = $this->getMockRequestMethod('{"success": true}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->verify(12345); + $this->assertTrue($response->isSuccess()); + } + public function testDefaultRequestMethodWithCurl(): void { GlobalState::$isCurlAvailable = true; diff --git a/tests/ReCaptcha/RequestMethod/CurlPostTest.php b/tests/ReCaptcha/RequestMethod/CurlPostTest.php index 539d0c1..5095c02 100644 --- a/tests/ReCaptcha/RequestMethod/CurlPostTest.php +++ b/tests/ReCaptcha/RequestMethod/CurlPostTest.php @@ -143,6 +143,25 @@ public function testLegacyConstructorOverrideSiteVerifyUrl(): void $this->assertEquals('RESPONSEBODY', $response); } + public function testLegacyNamedArgumentsCanInjectCurlWrapper(): void + { + $url = 'OVERRIDE'; + $pc = new CurlPost(curl: new Curl(), siteVerifyUrl: $url); + $response = $pc->submit(new RequestParameters('secret', 'response')); + + $this->assertEquals($url, CurlPostGlobalState::$initUrl); + $this->assertEquals('RESPONSEBODY', $response); + } + + public function testCurlWrapperMethodsRemainUntyped(): void + { + $setoptArray = new \ReflectionMethod(Curl::class, 'setoptArray'); + $close = new \ReflectionMethod(Curl::class, 'close'); + + $this->assertFalse($setoptArray->hasReturnType()); + $this->assertFalse($close->hasReturnType()); + } + public function testConnectionFailureReturnsError(): void { CurlPostGlobalState::$execResponse = false; diff --git a/tests/ReCaptcha/RequestMethod/SocketPostTest.php b/tests/ReCaptcha/RequestMethod/SocketPostTest.php index 06f3a39..8e7181d 100644 --- a/tests/ReCaptcha/RequestMethod/SocketPostTest.php +++ b/tests/ReCaptcha/RequestMethod/SocketPostTest.php @@ -201,6 +201,33 @@ public function testLegacyConstructorOverrideSiteVerifyUrl(): void $this->assertTrue(SocketPostGlobalState::$fcloseCalled); } + public function testLegacyNamedArgumentsCanInjectSocketWrapper(): void + { + SocketPostGlobalState::$fgetsResponses = [ + "HTTP/1.0 200 OK\r\n", + "Content-Type: application/json\r\n", + "\r\n", + 'RESPONSEBODY', + ]; + + $url = 'https://custom.recaptcha.net/recaptcha/api/siteverify'; + $sp = new SocketPost(socket: new Socket(), siteVerifyUrl: $url); + $response = $sp->submit(new RequestParameters('secret', 'response')); + + $this->assertEquals('ssl://custom.recaptcha.net', SocketPostGlobalState::$fsockopenHostname); + $this->assertStringContainsString('POST /recaptcha/api/siteverify HTTP/1.0', SocketPostGlobalState::$fwriteData); + $this->assertEquals('RESPONSEBODY', $response); + $this->assertTrue(SocketPostGlobalState::$fcloseCalled); + } + + public function testSocketWrapperKeepsLegacyMethods(): void + { + $class = new \ReflectionClass(Socket::class); + + $this->assertTrue($class->hasMethod('fgets')); + $this->assertTrue($class->hasMethod('feof')); + } + public function testSubmitReturnsResponseWhenHttp11(): void { SocketPostGlobalState::$fgetsResponses = [ diff --git a/tests/ReCaptcha/ResponseTest.php b/tests/ReCaptcha/ResponseTest.php index d95cf70..fb00970 100644 --- a/tests/ReCaptcha/ResponseTest.php +++ b/tests/ReCaptcha/ResponseTest.php @@ -99,10 +99,22 @@ public static function provideJson(): array '{"success": true, "error-codes": ["test"], "hostname": "google.com"}', true, [], 'google.com', null, null, null, null, ], + [ + '{"success": "true"}', + true, [], null, null, null, null, null, + ], + [ + '{"success": 1}', + true, [], null, null, null, null, null, + ], [ '{"success": false}', false, [ReCaptcha::E_UNKNOWN_ERROR], null, null, null, null, null, ], + [ + '{}', + false, [ReCaptcha::E_INVALID_JSON], null, null, null, null, null, + ], [ '{"success": false, "hostname": "google.com"}', false, [ReCaptcha::E_UNKNOWN_ERROR], 'google.com', null, null, null, null, From 9c0e81b078293f77f5a0d5fb90300a6a53be3723 Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:33:57 +0500 Subject: [PATCH 3/8] Version Bump 1.5.1 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 599cd3a..3b45036 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ and v3. - reCAPTCHA: https://cloud.google.com/security/products/recaptcha - This repo: https://github.com/google/recaptcha - Hosted demo: https://recaptcha-demo.appspot.com/ -- Version: 1.5.0 +- Version: 1.5.1 - License: BSD, see [LICENSE](LICENSE) > [!IMPORTANT] From 38058ecca2fd9e0b5e6ebb99b0a0de6b79f8ae1e Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:45:37 +0500 Subject: [PATCH 4/8] Restore 1.x API compatibility --- README.md | 27 +++++++++++++++++++ src/ReCaptcha/ReCaptcha.php | 2 +- .../ReCaptcha/RequestMethod/CurlPostTest.php | 13 ++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3b45036..dd0f648 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,33 @@ The 1.x line preserves compatibility for the public request and response APIs. See [Public API compatibility](ARCHITECTURE.md#public-api-compatibility) for details on which API changes require a major release. +The following classes and interfaces are treated as public API in 1.x: + +- `ReCaptcha\ReCaptcha` +- `ReCaptcha\RequestMethod` +- `ReCaptcha\Response` +- `ReCaptcha\RequestParameters` +- `ReCaptcha\RequestMethod\Post` +- `ReCaptcha\RequestMethod\CurlPost` +- `ReCaptcha\RequestMethod\SocketPost` +- `ReCaptcha\RequestMethod\Curl` +- `ReCaptcha\RequestMethod\Socket` + +### 1.5.1 compatibility release notes + +The 1.5.1 compatibility release restores 1.x API behavior while keeping the +hardening and reliability updates from 1.5.0: + +- `RequestMethod::submit()` remains compatible with legacy implementations, and + `ReCaptcha::verify()` validates non-string request-method responses. +- Public non-final DTOs (`Response`, `RequestParameters`) remain extendable. +- Legacy `CurlPost` and `SocketPost` constructor forms are supported, + including source-compatible named arguments. +- Legacy `Curl` and `Socket` wrappers are available for source compatibility. +- `SocketPost` closes the handle when timeout setup fails. +- `CurlPost` always closes cURL handles. +- Fallback request methods continue to enforce TLS peer verification. + ### Examples You can see examples of each reCAPTCHA type in [examples/](examples/). You can diff --git a/src/ReCaptcha/ReCaptcha.php b/src/ReCaptcha/ReCaptcha.php index 3a8d099..2aec2b7 100644 --- a/src/ReCaptcha/ReCaptcha.php +++ b/src/ReCaptcha/ReCaptcha.php @@ -49,7 +49,7 @@ class ReCaptcha * * @var string */ - public const VERSION = 'php_1.5.0'; + public const VERSION = 'php_1.5.1'; /** * URL for reCAPTCHA siteverify API. diff --git a/tests/ReCaptcha/RequestMethod/CurlPostTest.php b/tests/ReCaptcha/RequestMethod/CurlPostTest.php index 5095c02..6c130c9 100644 --- a/tests/ReCaptcha/RequestMethod/CurlPostTest.php +++ b/tests/ReCaptcha/RequestMethod/CurlPostTest.php @@ -56,6 +56,8 @@ class CurlPostGlobalState public static ?array $setoptArrayOptions = null; public static bool|string $execResponse = 'RESPONSEBODY'; + + public static bool $closeCalled = false; } /** @@ -93,7 +95,7 @@ function curl_exec(\stdClass $ch): bool|string */ function curl_close(\stdClass $ch): void { - // no-op mock + CurlPostGlobalState::$closeCalled = true; } /** @@ -108,6 +110,7 @@ protected function setUp(): void CurlPostGlobalState::$initUrl = null; CurlPostGlobalState::$setoptArrayOptions = null; CurlPostGlobalState::$execResponse = 'RESPONSEBODY'; + CurlPostGlobalState::$closeCalled = false; } public function testSubmit(): void @@ -170,4 +173,12 @@ public function testConnectionFailureReturnsError(): void $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response); } + + public function testCurlWrapperCloseDelegatesToNativeFunction(): void + { + $curl = new Curl(); + $curl->close(new \stdClass()); + + $this->assertTrue(CurlPostGlobalState::$closeCalled); + } } From e8d1dd22bb3497c7a43d8fa24271787df3e145c0 Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:53:02 +0500 Subject: [PATCH 5/8] Version Bump --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a9ec6e8..b5d7641 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ }, "extra": { "branch-alias": { - "dev-main": "1.4.x-dev" + "dev-main": "1.5.x-dev" } }, "scripts": { From 8bf49b07237caf189bac6871bfc672a2b11e1be6 Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:54:31 +0500 Subject: [PATCH 6/8] Version Bump --- README.md | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/README.md b/README.md index dd0f648..0e4a109 100644 --- a/README.md +++ b/README.md @@ -173,32 +173,6 @@ The 1.x line preserves compatibility for the public request and response APIs. See [Public API compatibility](ARCHITECTURE.md#public-api-compatibility) for details on which API changes require a major release. -The following classes and interfaces are treated as public API in 1.x: - -- `ReCaptcha\ReCaptcha` -- `ReCaptcha\RequestMethod` -- `ReCaptcha\Response` -- `ReCaptcha\RequestParameters` -- `ReCaptcha\RequestMethod\Post` -- `ReCaptcha\RequestMethod\CurlPost` -- `ReCaptcha\RequestMethod\SocketPost` -- `ReCaptcha\RequestMethod\Curl` -- `ReCaptcha\RequestMethod\Socket` - -### 1.5.1 compatibility release notes - -The 1.5.1 compatibility release restores 1.x API behavior while keeping the -hardening and reliability updates from 1.5.0: - -- `RequestMethod::submit()` remains compatible with legacy implementations, and - `ReCaptcha::verify()` validates non-string request-method responses. -- Public non-final DTOs (`Response`, `RequestParameters`) remain extendable. -- Legacy `CurlPost` and `SocketPost` constructor forms are supported, - including source-compatible named arguments. -- Legacy `Curl` and `Socket` wrappers are available for source compatibility. -- `SocketPost` closes the handle when timeout setup fails. -- `CurlPost` always closes cURL handles. -- Fallback request methods continue to enforce TLS peer verification. ### Examples From 4f41dba1c5d8dafc873004b392594ef61b67b43b Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Sat, 2 May 2026 14:16:33 +0500 Subject: [PATCH 7/8] refresh composer.lock --- composer.lock | 74 +++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/composer.lock b/composer.lock index 7f2cfe6..ca029ca 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6f9f7f08c81bbc8765f5b1ea64677736", + "content-hash": "5a1a38d86c0d9b0bff77a744deb968ab", "packages": [], "packages-dev": [ { @@ -1222,11 +1222,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.51", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", - "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -1271,7 +1271,7 @@ "type": "github" } ], - "time": "2026-04-21T18:22:01+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -1658,16 +1658,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.1.7", + "version": "13.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd" + "reference": "f49a2b5e51ffb33421745368cc099cf66830d71b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddd6401641861cdef94b922ef10d484f436e8dcd", - "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f49a2b5e51ffb33421745368cc099cf66830d71b", + "reference": "f49a2b5e51ffb33421745368cc099cf66830d71b", "shasum": "" }, "require": { @@ -1681,7 +1681,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.1.3", + "phpunit/php-code-coverage": "^14.1.6", "phpunit/php-file-iterator": "^7.0.0", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", @@ -1737,7 +1737,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.7" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.8" }, "funding": [ { @@ -1745,7 +1745,7 @@ "type": "other" } ], - "time": "2026-04-18T06:14:52+00:00" + "time": "2026-05-01T04:22:45+00:00" }, { "name": "psr/container", @@ -3722,16 +3722,16 @@ }, { "name": "symfony/config", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39" + "reference": "7e712ee3c98ec114f674adc4fbad4c2fe7526b9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39", - "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39", + "url": "https://api.github.com/repos/symfony/config/zipball/7e712ee3c98ec114f674adc4fbad4c2fe7526b9c", + "reference": "7e712ee3c98ec114f674adc4fbad4c2fe7526b9c", "shasum": "" }, "require": { @@ -3776,7 +3776,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v8.0.8" + "source": "https://github.com/symfony/config/tree/v8.0.9" }, "funding": [ { @@ -3796,20 +3796,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/console", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", "shasum": "" }, "require": { @@ -3866,7 +3866,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.8" + "source": "https://github.com/symfony/console/tree/v8.0.9" }, "funding": [ { @@ -3886,7 +3886,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3957,16 +3957,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", - "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", "shasum": "" }, "require": { @@ -4018,7 +4018,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" }, "funding": [ { @@ -4038,7 +4038,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4118,16 +4118,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a" + "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a", - "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", + "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", "shasum": "" }, "require": { @@ -4164,7 +4164,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.8" + "source": "https://github.com/symfony/filesystem/tree/v8.0.9" }, "funding": [ { @@ -4184,7 +4184,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/finder", @@ -5347,5 +5347,5 @@ "php": ">=8.4" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From 989f6e4fcbc6c054e2fe26ae1b70c08d828a9da4 Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Sun, 3 May 2026 22:20:12 +0500 Subject: [PATCH 8/8] fix(recaptcha): restore 1.4.x compatibility and update docs (1.5.1) Co-authored-by: Copilot --- ARCHITECTURE.md | 22 +++++++++++++++ CONTRIBUTING.md | 19 +++++++++++++ README.md | 2 +- examples/recaptcha-request-curl.php | 4 +-- examples/recaptcha-request-post.php | 4 +-- examples/recaptcha-request-socket.php | 4 +-- examples/recaptcha-v2-checkbox-explicit.php | 4 +-- examples/recaptcha-v2-checkbox.php | 4 +-- examples/recaptcha-v2-invisible.php | 4 +-- examples/recaptcha-v3-verify.php | 6 ++--- src/ReCaptcha/ReCaptcha.php | 15 ++++++----- src/ReCaptcha/RequestMethod.php | 2 +- src/ReCaptcha/RequestMethod/Curl.php | 3 +-- src/ReCaptcha/RequestMethod/SocketPost.php | 4 +-- src/ReCaptcha/RequestParameters.php | 8 +++--- tests/ReCaptcha/ReCaptchaTest.php | 27 ++++++++++++++----- .../ReCaptcha/RequestMethod/CurlPostTest.php | 8 ------ tests/ReCaptcha/ResponseTest.php | 16 +++++------ 18 files changed, 99 insertions(+), 57 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4e794e5..3f115d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,3 +55,25 @@ constructor argument forms, should be reserved for a major release. The `RequestMethod::submit()` interface intentionally keeps its 1.x-compatible native signature. Implementations are still expected to return the body of the reCAPTCHA response as a string. + +### 1.5.1 Compatibility Focus + +Release 1.5.1 restores 1.4.x compatibility and keeps the 1.5.0 reliability fixes. + +- Restored 1.x public API contracts by removing narrowing type hints and `readonly` modifiers from public non-final DTOs. +- Kept custom `RequestMethod` implementations working without native return types. +- Kept legacy input handling for compatibility. +- Fixed deprecated `curl_close()` usage for PHP 8.5+. +- Kept the cURL handle cleanup, HTTP/1.1 handling, and TLS verification changes. + +### BC Testing + +The test suite covers the backward compatibility cases below: + +- `testLegacyRequestMethodImplementationWithoutReturnTypeCanBeUsed()`: Custom implementations without strict return types. +- `testNonStringRequestMethodResponseReturnsBadResponse()`: Non-string responses from custom implementations. +- `testScalarResponseIsAccepted()`: Scalar inputs such as integers in `verify()`. +- `testZeroAsStringIsValidResponse()`: The "0" response token. +- `testVerifyReturnsErrorOnNullResponse()`: Null input handling. + +These tests document expected 1.x behavior. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18c44ae..e4f6c89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,25 @@ a good idea to run them locally before submission to avoid getting things bounced back. That said, tests can be a little daunting so feel free to submit your PR and ask for help. +### Backward Compatibility Testing + +Changes to public APIs must preserve backward compatibility within the 1.x +release line. When changes affect public methods, parameters, or return types, +include tests for: + +1. Legacy implementation support. If interface contracts change, verify that old + implementations still work without modification. +2. Type coercion scenarios. Verify that null, scalar, and non-standard inputs + behave as they did in 1.4.x. +3. Custom `RequestMethod` implementations. Verify that user-provided + implementations continue to work without return type declarations. + +See the BC-focused tests in [`ReCaptchaTest.php`](./tests/ReCaptcha/ReCaptchaTest.php): +- `testLegacyRequestMethodImplementationWithoutReturnTypeCanBeUsed()` +- `testNonStringRequestMethodResponseReturnsBadResponse()` +- `testScalarResponseIsAccepted()` +- `testZeroAsStringIsValidResponse()` + ## Code reviews All submissions, including submissions by project members, require review. diff --git a/README.md b/README.md index 0e4a109..a4a32b2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ This library comes in when you need to verify the user's response. On the PHP side you need the response from the reCAPTCHA service and secret key from your credentials. Instantiate the `ReCaptcha` class with your secret key, specify any additional validation rules, and then call `verify()` with the reCAPTCHA -response (usually in `$_POST[\ReCaptcha\ReCaptcha::USER_TOKEN_PARAMETER]` or the +response (usually in `$_POST[\ReCaptcha\ReCaptcha::RESPONSE_KEY]` or the response from `grecaptcha.execute()` in JS which is in `$gRecaptchaResponse` in the example) and user's IP address. For example: diff --git a/examples/recaptcha-request-curl.php b/examples/recaptcha-request-curl.php index 5fe0629..4be10a9 100644 --- a/examples/recaptcha-request-curl.php +++ b/examples/recaptcha-request-curl.php @@ -89,14 +89,14 @@

POST data

POST data
POST data
POST data
POST data
POST data
secret, $response, $remoteIp, self::VERSION); $rawResponse = $this->requestMethod->submit($params); + // @phpstan-ignore function.alreadyNarrowedType if (!is_string($rawResponse)) { return new Response(false, [self::E_BAD_RESPONSE]); } @@ -252,7 +253,7 @@ public function verify($response, $remoteIp = null) * Provide a hostname to match against in verify() * This should be without a protocol or trailing slash, e.g. www.google.com. * - * @param mixed $hostname Expected hostname + * @param string $hostname Expected hostname * * @return ReCaptcha Current instance for fluent interface */ @@ -266,7 +267,7 @@ public function setExpectedHostname($hostname) /** * Provide an APK package name to match against in verify(). * - * @param mixed $apkPackageName Expected APK package name + * @param string $apkPackageName Expected APK package name * * @return ReCaptcha Current instance for fluent interface */ @@ -281,7 +282,7 @@ public function setExpectedApkPackageName($apkPackageName) * Provide an action to match against in verify() * This should be set per page. * - * @param mixed $action Expected action + * @param string $action Expected action * * @return ReCaptcha Current instance for fluent interface */ @@ -296,7 +297,7 @@ public function setExpectedAction($action) * Provide a threshold to meet or exceed in verify() * Threshold should be a float between 0 and 1 which will be tested as response >= threshold. * - * @param mixed $threshold Expected threshold + * @param float $threshold Expected threshold * * @return ReCaptcha Current instance for fluent interface */ @@ -310,7 +311,7 @@ public function setScoreThreshold($threshold) /** * Provide a timeout in seconds to test against the challenge timestamp in verify(). * - * @param mixed $timeoutSeconds Maximum time (seconds) elapsed since the challenge timestamp + * @param int $timeoutSeconds Maximum time (seconds) elapsed since the challenge timestamp * * @return ReCaptcha Current instance for fluent interface */ diff --git a/src/ReCaptcha/RequestMethod.php b/src/ReCaptcha/RequestMethod.php index 1255787..85d9ac1 100644 --- a/src/ReCaptcha/RequestMethod.php +++ b/src/ReCaptcha/RequestMethod.php @@ -49,7 +49,7 @@ interface RequestMethod * * @param RequestParameters $params Request parameters * - * @return mixed Body of the reCAPTCHA response + * @return string Body of the reCAPTCHA response */ public function submit(RequestParameters $params); } diff --git a/src/ReCaptcha/RequestMethod/Curl.php b/src/ReCaptcha/RequestMethod/Curl.php index b65cc49..b783ba7 100644 --- a/src/ReCaptcha/RequestMethod/Curl.php +++ b/src/ReCaptcha/RequestMethod/Curl.php @@ -84,7 +84,6 @@ public function exec($ch) */ public function close($ch) { - // @phpstan-ignore argument.type - curl_close($ch); + // PHP automatically closes curl resources on destruction. } } diff --git a/src/ReCaptcha/RequestMethod/SocketPost.php b/src/ReCaptcha/RequestMethod/SocketPost.php index e5fdba4..f2e8051 100644 --- a/src/ReCaptcha/RequestMethod/SocketPost.php +++ b/src/ReCaptcha/RequestMethod/SocketPost.php @@ -45,8 +45,8 @@ /** * Sends a POST request to the reCAPTCHA service, but makes use of fsockopen() - * instead of get_file_contents(). This is to account for people who may be on - * servers where allow_url_open is disabled. + * instead of file_get_contents(). This is to account for people who may be on + * servers where allow_url_fopen is disabled. */ class SocketPost implements RequestMethod { diff --git a/src/ReCaptcha/RequestParameters.php b/src/ReCaptcha/RequestParameters.php index 089c498..80dcda6 100644 --- a/src/ReCaptcha/RequestParameters.php +++ b/src/ReCaptcha/RequestParameters.php @@ -55,10 +55,10 @@ class RequestParameters /** * Initialise parameters. * - * @param mixed $secret site secret - * @param mixed $response value from g-captcha-response form field - * @param mixed $remoteIp user's IP address - * @param mixed $version version of this client library + * @param string $secret site secret + * @param string $response value from g-recaptcha-response form field + * @param null|string $remoteIp user's IP address + * @param null|string $version version of this client library */ public function __construct($secret, $response, $remoteIp = null, $version = null) { diff --git a/tests/ReCaptcha/ReCaptchaTest.php b/tests/ReCaptcha/ReCaptchaTest.php index 0e15ba0..2e78f4c 100644 --- a/tests/ReCaptcha/ReCaptchaTest.php +++ b/tests/ReCaptcha/ReCaptchaTest.php @@ -124,20 +124,16 @@ public function testVerifyReturnsErrorOnMissingResponse(): void public function testVerifyReturnsErrorOnNullResponse(): void { $rc = new ReCaptcha('secret'); + + /** @phpstan-ignore argument.type */ $response = $rc->verify(null); $this->assertFalse($response->isSuccess()); $this->assertEquals([ReCaptcha::E_MISSING_INPUT_RESPONSE], $response->getErrorCodes()); } - public function testRequestMethodSubmitKeepsLegacyReturnTypeCompatibility(): void - { - $submit = new \ReflectionMethod(RequestMethod::class, 'submit'); - - $this->assertFalse($submit->hasReturnType()); - } - public function testLegacyRequestMethodImplementationWithoutReturnTypeCanBeUsed(): void { + // BC: Legacy custom RequestMethod implementations without strict return types should still work $method = new class implements RequestMethod { public function submit(RequestParameters $params) { @@ -153,9 +149,11 @@ public function submit(RequestParameters $params) public function testNonStringRequestMethodResponseReturnsBadResponse(): void { + // Keep the 1.x compatibility path open for older custom RequestMethod implementations. $method = new class implements RequestMethod { public function submit(RequestParameters $params) { + // @phpstan-ignore return.type return false; } }; @@ -167,6 +165,17 @@ public function submit(RequestParameters $params) $this->assertEquals([ReCaptcha::E_BAD_RESPONSE], $response->getErrorCodes()); } + public function testObjectValuesDoNotCrashLegacySetters(): void + { + $method = $this->getMockRequestMethod('{"success": true, "hostname": ""}'); + $rc = new ReCaptcha('secret', $method); + + /** @phpstan-ignore argument.type */ + $response = $rc->setExpectedHostname(new \stdClass())->verify('response'); + + $this->assertTrue($response->isSuccess()); + } + public function testZeroAsStringIsValidSecret(): void { $rc = new ReCaptcha('0'); @@ -185,6 +194,8 @@ public function testScalarResponseIsAccepted(): void { $method = $this->getMockRequestMethod('{"success": true}'); $rc = new ReCaptcha('secret', $method); + + /** @phpstan-ignore argument.type */ $response = $rc->verify(12345); $this->assertTrue($response->isSuccess()); } @@ -312,6 +323,8 @@ public function testScoreThresholdAcceptsNumericString(): void { $method = $this->getMockRequestMethod('{"success": true, "score": "0.9"}'); $rc = new ReCaptcha('secret', $method); + + /** @phpstan-ignore argument.type */ $response = $rc->setScoreThreshold('0.5')->verify('response'); $this->assertTrue($response->isSuccess()); } diff --git a/tests/ReCaptcha/RequestMethod/CurlPostTest.php b/tests/ReCaptcha/RequestMethod/CurlPostTest.php index 6c130c9..fdbe6d2 100644 --- a/tests/ReCaptcha/RequestMethod/CurlPostTest.php +++ b/tests/ReCaptcha/RequestMethod/CurlPostTest.php @@ -173,12 +173,4 @@ public function testConnectionFailureReturnsError(): void $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response); } - - public function testCurlWrapperCloseDelegatesToNativeFunction(): void - { - $curl = new Curl(); - $curl->close(new \stdClass()); - - $this->assertTrue(CurlPostGlobalState::$closeCalled); - } } diff --git a/tests/ReCaptcha/ResponseTest.php b/tests/ReCaptcha/ResponseTest.php index fb00970..45b8bdc 100644 --- a/tests/ReCaptcha/ResponseTest.php +++ b/tests/ReCaptcha/ResponseTest.php @@ -53,7 +53,7 @@ class ResponseTest extends TestCase * @param array $errorCodes */ #[DataProvider('provideJson')] - public function testFromJson(string $json, bool $success, array $errorCodes, ?string $hostname, ?string $challengeTs, ?string $apkPackageName, ?float $score, ?string $action): void + public function testFromJson(mixed $json, bool $success, array $errorCodes, ?string $hostname, ?string $challengeTs, ?string $apkPackageName, ?float $score, ?string $action): void { $response = Response::fromJson($json); $this->assertEquals($success, $response->isSuccess()); @@ -66,7 +66,7 @@ public function testFromJson(string $json, bool $success, array $errorCodes, ?st } /** - * @return array, 3: null|string, 4: null|string, 5: null|string, 6: null|float, 7: null|string}> + * @return array, 3: null|string, 4: null|string, 5: null|string, 6: null|float, 7: null|string}> */ public static function provideJson(): array { @@ -123,6 +123,10 @@ public static function provideJson(): array 'BAD JSON', false, [ReCaptcha::E_INVALID_JSON], null, null, null, null, null, ], + [ + null, + false, [ReCaptcha::E_INVALID_JSON], null, null, null, null, null, + ], [ '{"success": false, "error-codes": "invalid-input-secret"}', false, [ReCaptcha::E_UNKNOWN_ERROR], null, null, null, null, null, @@ -138,14 +142,6 @@ public static function provideJson(): array ]; } - public function testFromJsonReturnsInvalidJsonForNull(): void - { - $response = Response::fromJson(null); - - $this->assertFalse($response->isSuccess()); - $this->assertEquals([ReCaptcha::E_INVALID_JSON], $response->getErrorCodes()); - } - public function testClassRemainsExtendable(): void { $class = new \ReflectionClass(Response::class);