diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 794ba72..3f115d5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -38,3 +38,42 @@ 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`, 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
+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.
+
+### 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 55f77ee..a4a32b2 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]
@@ -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:
@@ -169,6 +169,11 @@ $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/composer.json b/composer.json
index 0639a0d..b5d7641 100644
--- a/composer.json
+++ b/composer.json
@@ -25,14 +25,14 @@
},
"extra": {
"branch-alias": {
- "dev-main": "1.4.x-dev"
+ "dev-main": "1.5.x-dev"
}
},
"scripts": {
"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/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"
}
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 = $secret;
- if (null !== $requestMethod) {
+ if (!is_null($requestMethod)) {
$this->requestMethod = $requestMethod;
} elseif (function_exists('curl_version')) {
$this->requestMethod = new RequestMethod\CurlPost();
@@ -180,40 +205,45 @@ 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 string $response the user response token provided by reCAPTCHA, verifying the user on your site
+ * @param string $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 (empty($response)) {
return new Response(false, [self::E_MISSING_INPUT_RESPONSE]);
}
$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 = [];
- if (null !== $this->hostname && 0 !== strcasecmp($this->hostname, $initialResponse->getHostname())) {
+ if (isset($this->hostname) && 0 !== strcasecmp($this->hostname, $initialResponse->getHostname())) {
$validationErrors[] = self::E_HOSTNAME_MISMATCH;
}
- if (null !== $this->apkPackageName && 0 !== strcasecmp($this->apkPackageName, $initialResponse->getApkPackageName())) {
+ if (isset($this->apkPackageName) && 0 !== strcasecmp($this->apkPackageName, $initialResponse->getApkPackageName())) {
$validationErrors[] = self::E_APK_PACKAGE_NAME_MISMATCH;
}
- if (null !== $this->action && 0 !== strcasecmp($this->action, $initialResponse->getAction())) {
+ if (isset($this->action) && 0 !== strcasecmp($this->action, $initialResponse->getAction())) {
$validationErrors[] = self::E_ACTION_MISMATCH;
}
- if (null !== $this->threshold && $this->threshold > $initialResponse->getScore()) {
+ if (isset($this->threshold) && $this->threshold > $initialResponse->getScore()) {
$validationErrors[] = self::E_SCORE_THRESHOLD_NOT_MET;
}
- if (null !== $this->timeoutSeconds) {
+ if (isset($this->timeoutSeconds)) {
$challengeTs = strtotime($initialResponse->getChallengeTs());
if ($challengeTs > 0 && time() - $challengeTs > $this->timeoutSeconds) {
@@ -244,7 +274,7 @@ public function verify(string $response, ?string $remoteIp = null): Response
*
* @return ReCaptcha Current instance for fluent interface
*/
- public function setExpectedHostname(string $hostname): self
+ public function setExpectedHostname($hostname)
{
$this->hostname = $hostname;
@@ -258,7 +288,7 @@ public function setExpectedHostname(string $hostname): self
*
* @return ReCaptcha Current instance for fluent interface
*/
- public function setExpectedApkPackageName(string $apkPackageName): self
+ public function setExpectedApkPackageName($apkPackageName)
{
$this->apkPackageName = $apkPackageName;
@@ -273,7 +303,7 @@ public function setExpectedApkPackageName(string $apkPackageName): self
*
* @return ReCaptcha Current instance for fluent interface
*/
- public function setExpectedAction(string $action): self
+ public function setExpectedAction($action)
{
$this->action = $action;
@@ -284,13 +314,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 float|int|string $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 = floatval($threshold);
return $this;
}
@@ -302,7 +332,7 @@ public function setScoreThreshold(float $threshold): self
*
* @return ReCaptcha Current instance for fluent interface
*/
- public function setChallengeTimeout(int $timeoutSeconds): self
+ public function setChallengeTimeout($timeoutSeconds)
{
$this->timeoutSeconds = $timeoutSeconds;
diff --git a/src/ReCaptcha/RequestMethod.php b/src/ReCaptcha/RequestMethod.php
index f39234a..12335ec 100644
--- a/src/ReCaptcha/RequestMethod.php
+++ b/src/ReCaptcha/RequestMethod.php
@@ -1,7 +1,5 @@
$options
+ *
+ * @return bool
+ */
+ public function setoptArray($ch, array $options)
+ {
+ if (false === $ch) {
+ return false;
+ }
+
+ return curl_setopt_array($ch, $options);
+ }
+
+ /**
+ * @see http://php.net/curl_exec
+ *
+ * Note: always used with CURLOPT_RETURNTRANSFER set, so this returns the
+ * response body as a string on success, or false on failure.
+ *
+ * @param \CurlHandle|false $ch
+ *
+ * @return false|string
+ */
+ public function exec($ch)
+ {
+ if (false === $ch) {
+ return false;
+ }
+
+ $result = curl_exec($ch);
+
+ return true === $result ? false : $result;
+ }
+}
diff --git a/src/ReCaptcha/RequestMethod/CurlPost.php b/src/ReCaptcha/RequestMethod/CurlPost.php
index 5ff831a..9b03256 100644
--- a/src/ReCaptcha/RequestMethod/CurlPost.php
+++ b/src/ReCaptcha/RequestMethod/CurlPost.php
@@ -1,7 +1,5 @@
curl = (is_null($curl)) ? new Curl() : $curl;
$this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl;
}
@@ -73,9 +82,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,18 +98,14 @@ 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;
- }
-
- return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}';
- } finally {
- curl_close($handle);
+ if (false !== $response) {
+ return $response;
}
+
+ return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}';
}
}
diff --git a/src/ReCaptcha/RequestMethod/Post.php b/src/ReCaptcha/RequestMethod/Post.php
index 6d2c04f..11c3d0e 100644
--- a/src/ReCaptcha/RequestMethod/Post.php
+++ b/src/ReCaptcha/RequestMethod/Post.php
@@ -1,7 +1,5 @@
siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl;
}
@@ -70,24 +70,24 @@ 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' => [
- 'verify_peer' => true,
- 'verify_peer_name' => true,
- ],
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => $params->toQueryString(),
'timeout' => 60,
],
+ 'ssl' => [
+ 'verify_peer' => true,
+ 'verify_peer_name' => true,
+ ],
];
$context = stream_context_create($options);
$response = file_get_contents($this->siteVerifyUrl, false, $context);
- if (is_string($response)) {
+ if (false !== $response) {
return $response;
}
diff --git a/src/ReCaptcha/RequestMethod/Socket.php b/src/ReCaptcha/RequestMethod/Socket.php
new file mode 100644
index 0000000..d768894
--- /dev/null
+++ b/src/ReCaptcha/RequestMethod/Socket.php
@@ -0,0 +1,149 @@
+handle = fsockopen($hostname, $port, $errno, $errstr, $resolvedTimeout);
+
+ if (false != $this->handle && 0 === $errno && '' === $errstr) {
+ return $this->handle;
+ }
+
+ return false;
+ }
+
+ /**
+ * fwrite.
+ *
+ * @see http://php.net/fwrite
+ *
+ * @param string $string
+ * @param int $length
+ *
+ * @return bool|int
+ */
+ public function fwrite($string, $length = null)
+ {
+ if (false === $this->handle || null === $this->handle) {
+ return false;
+ }
+
+ $resolvedLength = is_null($length) ? strlen($string) : max(0, $length);
+
+ return fwrite($this->handle, $string, $resolvedLength);
+ }
+
+ /**
+ * fgets.
+ *
+ * @see http://php.net/fgets
+ *
+ * @param int $length
+ *
+ * @return string
+ */
+ public function fgets($length = null)
+ {
+ if (false === $this->handle || null === $this->handle) {
+ return '';
+ }
+
+ $resolvedLength = is_null($length) ? null : max(0, $length);
+ $line = fgets($this->handle, $resolvedLength);
+
+ return false === $line ? '' : $line;
+ }
+
+ /**
+ * feof.
+ *
+ * @see http://php.net/feof
+ *
+ * @return bool
+ */
+ public function feof()
+ {
+ if (false === $this->handle || null === $this->handle) {
+ return true;
+ }
+
+ return feof($this->handle);
+ }
+
+ /**
+ * fclose.
+ *
+ * @see http://php.net/fclose
+ *
+ * @return bool
+ */
+ public function fclose()
+ {
+ if (false === $this->handle || null === $this->handle) {
+ return false;
+ }
+
+ return fclose($this->handle);
+ }
+}
diff --git a/src/ReCaptcha/RequestMethod/SocketPost.php b/src/ReCaptcha/RequestMethod/SocketPost.php
index 955a49d..51ae5f1 100644
--- a/src/ReCaptcha/RequestMethod/SocketPost.php
+++ b/src/ReCaptcha/RequestMethod/SocketPost.php
@@ -1,7 +1,5 @@
socket = (is_null($socket)) ? new Socket() : $socket;
$this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl;
}
@@ -69,23 +81,17 @@ 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 = '';
$urlParsed = parse_url($this->siteVerifyUrl);
- if (false === $urlParsed || !isset($urlParsed['host']) || !isset($urlParsed['path'])) {
+ if (false === $urlParsed || !isset($urlParsed['host'], $urlParsed['path'])) {
return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}';
}
- $handle = 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->fsockopen('ssl://'.$urlParsed['host'], 443, $errno, $errstr, 30)) {
return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}';
}
@@ -98,15 +104,15 @@ 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);
-
- fclose($handle);
+ $this->socket->fwrite($request);
+ $response = '';
- if (!is_string($response)) {
- $response = '';
+ while (!$this->socket->feof()) {
+ $response .= $this->socket->fgets(4096);
}
+ $this->socket->fclose();
+
if (1 !== preg_match('#^HTTP/1\.[01] 200 OK#', $response)) {
return '{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}';
}
diff --git a/src/ReCaptcha/RequestParameters.php b/src/ReCaptcha/RequestParameters.php
index fd20b14..65f4047 100644
--- a/src/ReCaptcha/RequestParameters.php
+++ b/src/ReCaptcha/RequestParameters.php
@@ -1,7 +1,5 @@
secret = $secret;
+ $this->response = $response;
+ $this->remoteIp = $remoteIp;
+ $this->version = $version;
+ }
/**
* Array representation.
*
* @return array array formatted parameters
*/
- public function toArray(): array
+ public function toArray()
{
$params = ['secret' => $this->secret, 'response' => $this->response];
@@ -84,7 +111,7 @@ public function toArray(): array
*
* @return string query string formatted parameters
*/
- public function toQueryString(): string
+ public function toQueryString()
{
return http_build_query($this->toArray(), '', '&');
}
diff --git a/src/ReCaptcha/Response.php b/src/ReCaptcha/Response.php
index 7cdf9e0..7f65966 100644
--- a/src/ReCaptcha/Response.php
+++ b/src/ReCaptcha/Response.php
@@ -1,7 +1,5 @@
+ */
+ private $errorCodes = [];
+
+ /**
+ * The hostname of the site where the reCAPTCHA was solved.
+ *
+ * @var string
+ */
+ private $hostname;
+
+ /**
+ * Timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ).
+ *
+ * @var string
+ */
+ private $challengeTs;
+
+ /**
+ * APK package name.
+ *
+ * @var string
+ */
+ private $apkPackageName;
+
+ /**
+ * Score assigned to the request.
+ *
+ * @var null|float
+ */
+ private $score;
+
+ /**
+ * Action as specified by the page.
+ *
+ * @var string
+ */
+ private $action;
+
/**
* Constructor.
*
- * @param bool $success success or failure
- * @param array $errorCodes error code strings
- * @param string $hostname the hostname of the site where the reCAPTCHA was solved
- * @param string $challengeTs timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
- * @param string $apkPackageName APK package name
- * @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 = '',
- ) {}
+ * @param bool $success
+ * @param array $errorCodes
+ * @param string $hostname
+ * @param string $challengeTs
+ * @param string $apkPackageName
+ * @param null|float $score
+ * @param string $action
+ */
+ public function __construct($success, array $errorCodes = [], $hostname = '', $challengeTs = '', $apkPackageName = '', $score = null, $action = '')
+ {
+ $this->success = $success;
+ $this->hostname = $hostname;
+ $this->challengeTs = $challengeTs;
+ $this->apkPackageName = $apkPackageName;
+ $this->score = $score;
+ $this->action = $action;
+ $this->errorCodes = $errorCodes;
+ }
/**
* Build the response from the expected JSON returned by the service.
+ *
+ * @param string $json
+ *
+ * @return Response
*/
- public static function fromJson(string $json): Response
+ 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]);
}
@@ -82,13 +134,12 @@ public static function fromJson(string $json): Response
$score = isset($responseData['score']) && is_numeric($responseData['score']) ? floatval($responseData['score']) : null;
$action = isset($responseData['action']) && is_string($responseData['action']) ? $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);
}
if (isset($responseData['error-codes']) && is_array($responseData['error-codes'])) {
- /** @var array $errorCodes */
- $errorCodes = $responseData['error-codes'];
+ $errorCodes = array_values(array_filter($responseData['error-codes'], 'is_string'));
return new Response(false, $errorCodes, $hostname, $challengeTs, $apkPackageName, $score, $action);
}
@@ -98,8 +149,10 @@ public static function fromJson(string $json): Response
/**
* Is success?
+ *
+ * @return bool
*/
- public function isSuccess(): bool
+ public function isSuccess()
{
return $this->success;
}
@@ -107,49 +160,59 @@ public function isSuccess(): bool
/**
* Get error codes.
*
- * @return array
+ * @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 +230,7 @@ public function getAction(): string
* error-codes: string[]
* }
*/
- public function toArray(): array
+ public function toArray()
{
return [
'success' => $this->isSuccess(),
diff --git a/src/autoload.php b/src/autoload.php
index b4b7c53..1866970 100644
--- a/src/autoload.php
+++ b/src/autoload.php
@@ -35,7 +35,7 @@
*/
spl_autoload_register(function ($class) {
- if (!str_starts_with($class, 'ReCaptcha\\')) {
+ if ('ReCaptcha\\' !== substr($class, 0, 10)) {
/* If the class does not lie under the "ReCaptcha" namespace,
* then we can exit immediately.
*/
diff --git a/tests/ReCaptcha/ReCaptchaTest.php b/tests/ReCaptcha/ReCaptchaTest.php
index 57f6e25..48055d7 100644
--- a/tests/ReCaptcha/ReCaptchaTest.php
+++ b/tests/ReCaptcha/ReCaptchaTest.php
@@ -1,7 +1,5 @@
expectException(\TypeError::class);
-
- /** @phpstan-ignore argument.type */
+ $this->expectException(\RuntimeException::class);
$rc = new ReCaptcha($invalid);
}
@@ -89,29 +63,11 @@ public function testExceptionThrownOnInvalidSecretType(mixed $invalid): void
public static function invalidSecretProvider(): array
{
return [
+ [''],
[null],
+ [0],
[new \stdClass()],
[[]],
- [0],
- ];
- }
-
- #[DataProvider('emptySecretProvider')]
- public function testExceptionThrownOnEmptySecret(mixed $emptySecret): void
- {
- $this->expectException(\RuntimeException::class);
-
- /** @phpstan-ignore argument.type */
- $rc = new ReCaptcha($emptySecret);
- }
-
- /**
- * @return array>
- */
- public static function emptySecretProvider(): array
- {
- return [
- [''],
];
}
@@ -123,40 +79,18 @@ public function testVerifyReturnsErrorOnMissingResponse(): void
$this->assertEquals([ReCaptcha::E_MISSING_INPUT_RESPONSE], $response->getErrorCodes());
}
- public function testZeroAsStringIsValidSecret(): void
- {
- $rc = new ReCaptcha('0');
- $this->assertInstanceOf(ReCaptcha::class, $rc);
- }
-
- public function testZeroAsStringIsValidResponse(): void
- {
- $method = $this->getMockRequestMethod('{"success": true}');
- $rc = new ReCaptcha('secret', $method);
- $response = $rc->verify('0');
- $this->assertTrue($response->isSuccess());
- }
-
- public function testDefaultRequestMethodWithCurl(): void
+ public function testDefaultRequestMethod(): void
{
- GlobalState::$isCurlAvailable = true;
$rc = new ReCaptcha('secret');
$reflection = new \ReflectionClass($rc);
$property = $reflection->getProperty('requestMethod');
$requestMethod = $property->getValue($rc);
- $this->assertInstanceOf(RequestMethod\CurlPost::class, $requestMethod);
- }
-
- public function testDefaultRequestMethodWithoutCurl(): void
- {
- GlobalState::$isCurlAvailable = false;
- $rc = new ReCaptcha('secret');
- $reflection = new \ReflectionClass($rc);
- $property = $reflection->getProperty('requestMethod');
- $requestMethod = $property->getValue($rc);
-
- $this->assertInstanceOf(RequestMethod\Post::class, $requestMethod);
+ if (function_exists('curl_version')) {
+ $this->assertInstanceOf(RequestMethod\CurlPost::class, $requestMethod);
+ } else {
+ $this->assertInstanceOf(RequestMethod\Post::class, $requestMethod);
+ }
}
public function testVerifyReturnsResponse(): void
@@ -192,19 +126,6 @@ public function testVerifyHostnameMisMatch(): void
$this->assertEquals([ReCaptcha::E_HOSTNAME_MISMATCH], $response->getErrorCodes());
}
- public function testVerifyHostnameMatchCaseInsensitive(): void
- {
- $method = $this->getMockRequestMethod('{"success": true, "hostname": "host.name"}');
- $rc = new ReCaptcha('secret', $method);
- $response = $rc->setExpectedHostname('HOST.NAME')->verify('response');
- $this->assertTrue($response->isSuccess());
-
- $method = $this->getMockRequestMethod('{"success": true, "hostname": "HOST.NAME"}');
- $rc = new ReCaptcha('secret', $method);
- $response = $rc->setExpectedHostname('host.name')->verify('response');
- $this->assertTrue($response->isSuccess());
- }
-
public function testVerifyApkPackageNameMatch(): void
{
$method = $this->getMockRequestMethod('{"success": true, "apk_package_name": "apk.name"}');
@@ -243,7 +164,8 @@ public function testVerifyAboveThreshold(): void
{
$method = $this->getMockRequestMethod('{"success": true, "score": "0.9"}');
$rc = new ReCaptcha('secret', $method);
- $response = $rc->setScoreThreshold(0.5)->verify('response');
+ // Passed as a string deliberately: setScoreThreshold() coerces via floatval().
+ $response = $rc->setScoreThreshold('0.5')->verify('response');
$this->assertTrue($response->isSuccess());
}
@@ -251,7 +173,8 @@ public function testVerifyBelowThreshold(): void
{
$method = $this->getMockRequestMethod('{"success": true, "score": "0.1"}');
$rc = new ReCaptcha('secret', $method);
- $response = $rc->setScoreThreshold(0.5)->verify('response');
+ // Passed as a string deliberately: setScoreThreshold() coerces via floatval().
+ $response = $rc->setScoreThreshold('0.5')->verify('response');
$this->assertFalse($response->isSuccess());
$this->assertEquals([ReCaptcha::E_SCORE_THRESHOLD_NOT_MET], $response->getErrorCodes());
}
@@ -277,19 +200,12 @@ public function testVerifyOverTimeout(): void
$this->assertEquals([ReCaptcha::E_CHALLENGE_TIMEOUT], $response->getErrorCodes());
}
- public function testVerifyWithInvalidChallengeTsAndTimeout(): void
- {
- $method = $this->getMockRequestMethod('{"success": true, "challenge_ts": "invalid-timestamp"}');
- $rc = new ReCaptcha('secret', $method);
- $response = $rc->setChallengeTimeout(60)->verify('response');
- $this->assertTrue($response->isSuccess());
- }
-
public function testVerifyMergesErrors(): void
{
$method = $this->getMockRequestMethod('{"success": false, "error-codes": ["initial-error"], "score": "0.1"}');
$rc = new ReCaptcha('secret', $method);
- $response = $rc->setScoreThreshold(0.5)->verify('response');
+ // Passed as a string deliberately: setScoreThreshold() coerces via floatval().
+ $response = $rc->setScoreThreshold('0.5')->verify('response');
$this->assertFalse($response->isSuccess());
$this->assertEquals(['initial-error', ReCaptcha::E_SCORE_THRESHOLD_NOT_MET], $response->getErrorCodes());
}
diff --git a/tests/ReCaptcha/RequestMethod/CurlPostTest.php b/tests/ReCaptcha/RequestMethod/CurlPostTest.php
index 88e4d39..f4fd8d9 100644
--- a/tests/ReCaptcha/RequestMethod/CurlPostTest.php
+++ b/tests/ReCaptcha/RequestMethod/CurlPostTest.php
@@ -1,7 +1,5 @@
- */
- public static ?array $setoptArrayOptions = null;
-
- public static bool|string $execResponse = 'RESPONSEBODY';
-}
-
-/**
- * Mock curl_init in the ReCaptcha\RequestMethod namespace.
- */
-function curl_init(?string $url = null): \stdClass
-{
- CurlPostGlobalState::$initUrl = $url;
-
- return new \stdClass();
-}
-
-/**
- * Mock curl_setopt_array in the ReCaptcha\RequestMethod namespace.
- *
- * @param array $options
- */
-function curl_setopt_array(\stdClass $ch, array $options): bool
-{
- CurlPostGlobalState::$setoptArrayOptions = $options;
-
- return true;
-}
-
-/**
- * Mock curl_exec in the ReCaptcha\RequestMethod namespace.
- */
-function curl_exec(\stdClass $ch): bool|string
-{
- return CurlPostGlobalState::$execResponse;
-}
-
-/**
- * Mock curl_close in the ReCaptcha\RequestMethod namespace.
- */
-function curl_close(\stdClass $ch): void
-{
- // no-op mock
-}
-
/**
* @internal
*
@@ -105,40 +50,85 @@ class CurlPostTest extends TestCase
{
protected function setUp(): void
{
- CurlPostGlobalState::$initUrl = null;
- CurlPostGlobalState::$setoptArrayOptions = null;
- CurlPostGlobalState::$execResponse = 'RESPONSEBODY';
+ if (!extension_loaded('curl')) {
+ $this->markTestSkipped(
+ 'The cURL extension is not available.'
+ );
+ }
}
public function testSubmit(): void
{
- $pc = new CurlPost();
+ $curl = $this->getMockBuilder(Curl::class)
+ ->disableOriginalConstructor()
+ ->getMock()
+ ;
+ $curl->expects($this->once())
+ ->method('init')
+ ->willReturn(new \stdClass())
+ ;
+ $curl->expects($this->once())
+ ->method('setoptArray')
+ ->willReturn(true)
+ ;
+ $curl->expects($this->once())
+ ->method('exec')
+ ->willReturn('RESPONSEBODY')
+ ;
+
+ $pc = new CurlPost($curl);
$response = $pc->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals(ReCaptcha::SITE_VERIFY_URL, CurlPostGlobalState::$initUrl);
-
- /** @var array $options */
- $options = CurlPostGlobalState::$setoptArrayOptions;
- $this->assertTrue($options[CURLOPT_POST]);
$this->assertEquals('RESPONSEBODY', $response);
}
public function testOverrideSiteVerifyUrl(): void
{
$url = 'OVERRIDE';
- $pc = new CurlPost($url);
- $response = $pc->submit(new RequestParameters('secret', 'response'));
- $this->assertEquals($url, CurlPostGlobalState::$initUrl);
+ $curl = $this->getMockBuilder(Curl::class)
+ ->disableOriginalConstructor()
+ ->getMock()
+ ;
+ $curl->expects($this->once())
+ ->method('init')
+ ->with($url)
+ ->willReturn(new \stdClass())
+ ;
+ $curl->expects($this->once())
+ ->method('setoptArray')
+ ->willReturn(true)
+ ;
+ $curl->expects($this->once())
+ ->method('exec')
+ ->willReturn('RESPONSEBODY')
+ ;
+
+ $pc = new CurlPost($curl, $url);
+ $response = $pc->submit(new RequestParameters('secret', 'response'));
$this->assertEquals('RESPONSEBODY', $response);
}
public function testConnectionFailureReturnsError(): void
{
- CurlPostGlobalState::$execResponse = false;
- $pc = new CurlPost();
+ $curl = $this->getMockBuilder(Curl::class)
+ ->disableOriginalConstructor()
+ ->getMock()
+ ;
+ $curl->expects($this->once())
+ ->method('init')
+ ->willReturn(new \stdClass())
+ ;
+ $curl->expects($this->once())
+ ->method('setoptArray')
+ ->willReturn(true)
+ ;
+ $curl->expects($this->once())
+ ->method('exec')
+ ->willReturn(false)
+ ;
+
+ $pc = new CurlPost($curl);
$response = $pc->submit(new RequestParameters('secret', 'response'));
-
$this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response);
}
}
diff --git a/tests/ReCaptcha/RequestMethod/PostTest.php b/tests/ReCaptcha/RequestMethod/PostTest.php
index fff18ee..875832f 100644
--- a/tests/ReCaptcha/RequestMethod/PostTest.php
+++ b/tests/ReCaptcha/RequestMethod/PostTest.php
@@ -1,7 +1,5 @@
assertEquals(1, $this->runcount, 'The assertion was ran');
}
+ public function testSSLContextOptions(): void
+ {
+ $req = new Post();
+ self::$assert = [$this, 'sslContextOptionsCallback'];
+ $req->submit($this->parameters);
+ $this->assertEquals(1, $this->runcount, 'The assertion was ran');
+ }
+
public function testOverrideVerifyUrl(): void
{
$req = new Post('https://over.ride/some/path');
@@ -97,101 +111,90 @@ public function connectionFailureResponse(): bool
}
/**
- * @param array $args
+ * @param array{0: string, 1: bool, 2: resource} $args
*/
- public function overrideUrlOptions(array $args): void
+ public function overrideUrlOptions(array $args): bool
{
++$this->runcount;
$this->assertEquals('https://over.ride/some/path', $args[0]);
+
+ return false;
}
/**
- * @param array $args
+ * @param array{0: string, 1: bool, 2: resource} $args
*/
- public function httpContextOptionsCallback(array $args): void
+ public function httpContextOptionsCallback(array $args): bool
{
++$this->runcount;
$this->assertCommonOptions($args);
- /** @var resource $context */
- $context = $args[2];
- $options = stream_context_get_options($context);
+ /** @var array> $options */
+ $options = stream_context_get_options($args[2]);
$this->assertArrayHasKey('http', $options);
- $this->assertArrayHasKey('ssl', $options);
- /** @var array $httpOptions */
- $httpOptions = $options['http'];
+ $this->assertArrayHasKey('method', $options['http']);
+ $this->assertEquals('POST', $options['http']['method']);
- /** @var array $sslOptions */
- $sslOptions = $options['ssl'];
+ $this->assertArrayHasKey('content', $options['http']);
+ $this->assertEquals($this->parameters->toQueryString(), $options['http']['content']);
- $this->assertArrayHasKey('method', $httpOptions);
- $this->assertEquals('POST', $httpOptions['method']);
+ $this->assertArrayHasKey('header', $options['http']);
+ $this->assertIsString($options['http']['header']);
+ $this->assertStringContainsStringIgnoringCase('Content-type: application/x-www-form-urlencoded', (string) $options['http']['header']);
- $this->assertArrayHasKey('content', $httpOptions);
- $this->assertEquals($this->parameters->toQueryString(), $httpOptions['content']);
+ $this->assertArrayHasKey('timeout', $options['http']);
+ $this->assertEquals(60, $options['http']['timeout']);
- $this->assertArrayHasKey('header', $httpOptions);
+ return false;
+ }
- /** @var string $header */
- $header = $httpOptions['header'];
- $this->assertStringContainsStringIgnoringCase('Content-type: application/x-www-form-urlencoded', $header);
+ /**
+ * @param array{0: string, 1: bool, 2: resource} $args
+ */
+ public function sslContextOptionsCallback(array $args): bool
+ {
+ ++$this->runcount;
+ $this->assertCommonOptions($args);
- $this->assertArrayHasKey('timeout', $httpOptions);
- $this->assertEquals(60, $httpOptions['timeout']);
+ /** @var array> $options */
+ $options = stream_context_get_options($args[2]);
+ $this->assertArrayHasKey('ssl', $options);
+ $this->assertArrayHasKey('verify_peer', $options['ssl']);
+ $this->assertTrue($options['ssl']['verify_peer']);
+ $this->assertArrayHasKey('verify_peer_name', $options['ssl']);
+ $this->assertTrue($options['ssl']['verify_peer_name']);
- $this->assertArrayHasKey('verify_peer', $sslOptions);
- $this->assertTrue((bool) $sslOptions['verify_peer']);
- $this->assertArrayHasKey('verify_peer_name', $sslOptions);
- $this->assertTrue((bool) $sslOptions['verify_peer_name']);
+ return false;
}
/**
- * @param array $args
+ * @param array{0: string, 1: bool, 2: resource} $args
*/
protected function assertCommonOptions(array $args): void
{
$this->assertCount(3, $args);
-
- /** @var string $url */
- $url = $args[0];
- $this->assertStringStartsWith('https://www.google.com/', $url);
+ $this->assertStringStartsWith('https://www.google.com/', $args[0]);
$this->assertFalse($args[1]);
$this->assertTrue(is_resource($args[2]), 'The context options should be a resource');
}
}
-function file_get_contents(string $filename, bool $use_include_path = false, mixed $context = null, int $offset = 0, ?int $length = null): false|string
+/**
+ * @param mixed ...$args
+ *
+ * @return false|string
+ */
+function file_get_contents(...$args)
{
- $args = func_get_args();
if (PostTest::$assert) {
- /** @var callable $assert */
- $assert = PostTest::$assert;
- $result = call_user_func($assert, $args);
- if (null === $result) {
- return '';
- }
-
- if (is_string($result)) {
- return $result;
- }
-
- if (false === $result) {
- return $result;
- }
+ $result = call_user_func(PostTest::$assert, $args);
- return false;
+ return is_string($result) ? $result : false;
}
// Since we can't represent maxlen in userland...
- $result = call_user_func_array('\file_get_contents', $args);
- if (is_string($result)) {
- return $result;
- }
-
- if (false === $result) {
- return $result;
- }
+ $result = call_user_func_array('file_get_contents', $args);
- return false;
+ return is_string($result) ? $result : false;
}
diff --git a/tests/ReCaptcha/RequestMethod/SocketPostTest.php b/tests/ReCaptcha/RequestMethod/SocketPostTest.php
index ec6193e..c6d9879 100644
--- a/tests/ReCaptcha/RequestMethod/SocketPostTest.php
+++ b/tests/ReCaptcha/RequestMethod/SocketPostTest.php
@@ -1,7 +1,5 @@
- */
- public static array $fgetsResponses = [];
- public static int $feofCount = 0;
- public static bool $fcloseCalled = false;
- public static bool $streamSetTimeoutSuccess = true;
-}
-
-/**
- * Mock fsockopen in the ReCaptcha\RequestMethod namespace.
- */
-function fsockopen(string $hostname, int $port = -1, int &$errno = 0, string &$errstr = '', ?float $timeout = null): false|\stdClass
-{
- SocketPostGlobalState::$fsockopenHostname = $hostname;
- $errno = SocketPostGlobalState::$fsockopenErrno;
- $errstr = SocketPostGlobalState::$fsockopenErrstr;
-
- return SocketPostGlobalState::$fsockopenSuccess ? new \stdClass() : false;
-}
-
-/**
- * Mock fwrite in the ReCaptcha\RequestMethod namespace.
- */
-function fwrite(\stdClass $handle, string $string, ?int $length = null): int
-{
- SocketPostGlobalState::$fwriteData .= $string;
-
- return strlen($string);
-}
-
-/**
- * Mock stream_get_contents in the ReCaptcha\RequestMethod namespace.
- */
-function stream_get_contents(\stdClass $handle, ?int $length = null, int $offset = -1): false|string
-{
- if (empty(SocketPostGlobalState::$fgetsResponses)) {
- return false;
- }
-
- $result = '';
- foreach (SocketPostGlobalState::$fgetsResponses as $response) {
- if (false !== $response) {
- $result .= $response;
- }
- }
- SocketPostGlobalState::$fgetsResponses = [];
-
- return $result;
-}
-
-/**
- * Mock stream_set_timeout in the ReCaptcha\RequestMethod namespace.
- */
-function stream_set_timeout(\stdClass $handle, int $seconds, int $microseconds = 0): bool
-{
- return SocketPostGlobalState::$streamSetTimeoutSuccess;
-}
-
-/**
- * Mock fclose in the ReCaptcha\RequestMethod namespace.
- */
-function fclose(\stdClass $handle): bool
-{
- SocketPostGlobalState::$fcloseCalled = true;
-
- return true;
-}
-
/**
* @internal
*
@@ -130,191 +48,101 @@ function fclose(\stdClass $handle): bool
*/
class SocketPostTest extends TestCase
{
- protected function setUp(): void
- {
- SocketPostGlobalState::$fsockopenHostname = null;
- SocketPostGlobalState::$fsockopenErrno = 0;
- SocketPostGlobalState::$fsockopenErrstr = '';
- SocketPostGlobalState::$fsockopenSuccess = true;
- SocketPostGlobalState::$fwriteData = '';
- SocketPostGlobalState::$fgetsResponses = [];
- SocketPostGlobalState::$fcloseCalled = false;
- SocketPostGlobalState::$streamSetTimeoutSuccess = true;
- }
-
- public function testSubmit(): void
+ public function testSubmitSuccess(): void
{
- SocketPostGlobalState::$fgetsResponses = [
- "HTTP/1.0 200 OK\r\n",
- "Content-Type: application/json\r\n",
- "\r\n",
- 'RESPONSEBODY',
- ];
-
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('ssl://www.google.com', SocketPostGlobalState::$fsockopenHostname);
- $this->assertStringContainsString('secret=secret', SocketPostGlobalState::$fwriteData);
- $this->assertStringContainsString('response=response', SocketPostGlobalState::$fwriteData);
+ $socket = $this->createMock(Socket::class);
+ $socket->expects($this->once())
+ ->method('fsockopen')
+ ->willReturn(true)
+ ;
+ $socket->expects($this->once())
+ ->method('fwrite')
+ ;
+ $socket->expects($this->once())
+ ->method('fgets')
+ ->willReturn("HTTP/1.0 200 OK\n\nRESPONSEBODY")
+ ;
+ $socket->expects($this->exactly(2))
+ ->method('feof')
+ ->willReturnOnConsecutiveCalls(false, true)
+ ;
+ $socket->expects($this->once())
+ ->method('fclose')
+ ->willReturn(true)
+ ;
+
+ $ps = new SocketPost($socket);
+ $response = $ps->submit(new RequestParameters('secret', 'response', 'remoteip', 'version'));
$this->assertEquals('RESPONSEBODY', $response);
- $this->assertTrue(SocketPostGlobalState::$fcloseCalled);
}
public function testOverrideSiteVerifyUrl(): 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($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->assertStringContainsString('secret=secret', SocketPostGlobalState::$fwriteData);
- $this->assertStringContainsString('response=response', SocketPostGlobalState::$fwriteData);
- $this->assertEquals('RESPONSEBODY', $response);
- $this->assertTrue(SocketPostGlobalState::$fcloseCalled);
- }
-
- public function testSubmitReturnsResponseWhenHttp11(): void
- {
- SocketPostGlobalState::$fgetsResponses = [
- "HTTP/1.1 200 OK\r\n",
- "Content-Type: application/json\r\n",
- "\r\n",
- 'RESPONSEBODY',
- ];
-
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
+ $socket = $this->createMock(Socket::class);
+ $socket->expects($this->once())
+ ->method('fsockopen')
+ ->with('ssl://over.ride', 443, 0, '', 30)
+ ->willReturn(true)
+ ;
+ $socket->expects($this->once())
+ ->method('fwrite')
+ ->with($this->matchesRegularExpression('/^POST \/some\/path.*Host: over\.ride/s'))
+ ;
+ $socket->expects($this->once())
+ ->method('fgets')
+ ->willReturn("HTTP/1.0 200 OK\n\nRESPONSEBODY")
+ ;
+ $socket->expects($this->exactly(2))
+ ->method('feof')
+ ->willReturnOnConsecutiveCalls(false, true)
+ ;
+ $socket->expects($this->once())
+ ->method('fclose')
+ ->willReturn(true)
+ ;
+
+ $ps = new SocketPost($socket, 'https://over.ride/some/path');
+ $response = $ps->submit(new RequestParameters('secret', 'response', 'remoteip', 'version'));
$this->assertEquals('RESPONSEBODY', $response);
- $this->assertTrue(SocketPostGlobalState::$fcloseCalled);
- }
-
- public function testStreamTimeoutFailureReturnsError(): void
- {
- SocketPostGlobalState::$streamSetTimeoutSuccess = false;
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response);
}
- public function testConnectionFailureWithValidHandleReturnsError(): void
+ public function testSubmitBadResponse(): void
{
- SocketPostGlobalState::$fsockopenSuccess = true;
- SocketPostGlobalState::$fsockopenErrno = 1;
- SocketPostGlobalState::$fsockopenErrstr = 'Connection refused';
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response);
- }
-
- public function testUrlFailureReturnsError(): void
- {
- $sp = new SocketPost('invalid_url');
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response);
- }
-
- public function testSubmitWithFgetsFailure(): void
- {
- SocketPostGlobalState::$fgetsResponses = [
- "HTTP/1.0 200 OK\r\n",
- false,
- "Content-Type: application/json\r\n",
- "\r\n",
- 'RESPONSEBODY',
- ];
-
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('RESPONSEBODY', $response);
- $this->assertTrue(SocketPostGlobalState::$fcloseCalled);
- }
-
- public function testMalformedResponseReturnsError(): void
- {
- SocketPostGlobalState::$fgetsResponses = [
- "HTTP/1.0 200 OK\r\n",
- "Content-Type: application/json\r\n",
- ];
-
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
+ $socket = $this->createMock(Socket::class);
+ $socket->expects($this->once())
+ ->method('fsockopen')
+ ->willReturn(true)
+ ;
+ $socket->expects($this->once())
+ ->method('fwrite')
+ ;
+ $socket->expects($this->once())
+ ->method('fgets')
+ ->willReturn('HTTP/1.0 500 NOPEn\nBOBBINS')
+ ;
+ $socket->expects($this->exactly(2))
+ ->method('feof')
+ ->willReturnOnConsecutiveCalls(false, true)
+ ;
+ $socket->expects($this->once())
+ ->method('fclose')
+ ->willReturn(true)
+ ;
+
+ $ps = new SocketPost($socket);
+ $response = $ps->submit(new RequestParameters('secret', 'response', 'remoteip', 'version'));
$this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}', $response);
}
public function testConnectionFailureReturnsError(): void
{
- SocketPostGlobalState::$fsockopenSuccess = false;
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
+ $socket = $this->createMock(Socket::class);
+ $socket->expects($this->once())
+ ->method('fsockopen')
+ ->willReturn(false)
+ ;
+ $ps = new SocketPost($socket);
+ $response = $ps->submit(new RequestParameters('secret', 'response', 'remoteip', 'version'));
$this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response);
}
-
- public function testBadResponseReturnsError(): void
- {
- SocketPostGlobalState::$fgetsResponses = [
- "HTTP/1.0 500 Internal Server Error\r\n",
- "\r\n",
- 'FAIL',
- ];
-
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}', $response);
- }
-
- public function testStreamGetContentsReturnsFalse(): void
- {
- SocketPostGlobalState::$fgetsResponses = [];
-
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}', $response);
- }
-
- public function testBadResponseReturnsErrorWhenHttp11(): void
- {
- SocketPostGlobalState::$fgetsResponses = [
- "HTTP/1.1 500 Internal Server Error\r\n",
- "\r\n",
- 'FAIL',
- ];
-
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}', $response);
- }
-
- public function testBadResponseReturnsErrorWhenHttp2(): void
- {
- SocketPostGlobalState::$fgetsResponses = [
- "HTTP/2.0 200 OK\r\n",
- "Content-Type: application/json\r\n",
- "\r\n",
- 'RESPONSEBODY',
- ];
-
- $sp = new SocketPost();
- $response = $sp->submit(new RequestParameters('secret', 'response'));
-
- $this->assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}', $response);
- }
}
diff --git a/tests/ReCaptcha/RequestParametersTest.php b/tests/ReCaptcha/RequestParametersTest.php
index 8b5bcdc..681b13b 100644
--- a/tests/ReCaptcha/RequestParametersTest.php
+++ b/tests/ReCaptcha/RequestParametersTest.php
@@ -1,7 +1,5 @@
'SECRET', 'response' => 'RESPONSE', 'remoteip' => 'REMOTEIP', 'version' => 'VERSION'],
- 'secret=SECRET&response=RESPONSE&remoteip=REMOTEIP&version=VERSION',
- ],
- [
- 'SECRET', 'RESPONSE', null, null,
+ 'secret=SECRET&response=RESPONSE&remoteip=REMOTEIP&version=VERSION'],
+ ['SECRET', 'RESPONSE', null, null,
['secret' => 'SECRET', 'response' => 'RESPONSE'],
- 'secret=SECRET&response=RESPONSE',
- ],
+ 'secret=SECRET&response=RESPONSE'],
];
}
}
diff --git a/tests/ReCaptcha/ResponseTest.php b/tests/ReCaptcha/ResponseTest.php
index c18a8a4..29e629f 100644
--- a/tests/ReCaptcha/ResponseTest.php
+++ b/tests/ReCaptcha/ResponseTest.php
@@ -1,7 +1,5 @@
$errorCodes
+ * @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
@@ -58,15 +56,15 @@ public function testFromJson(string $json, bool $success, array $errorCodes, ?st
$response = Response::fromJson($json);
$this->assertEquals($success, $response->isSuccess());
$this->assertEquals($errorCodes, $response->getErrorCodes());
- $this->assertEquals($hostname ?? '', $response->getHostname());
- $this->assertEquals($challengeTs ?? '', $response->getChallengeTs());
- $this->assertEquals($apkPackageName ?? '', $response->getApkPackageName());
+ $this->assertEquals($hostname, $response->getHostname());
+ $this->assertEquals($challengeTs, $response->getChallengeTs());
+ $this->assertEquals($apkPackageName, $response->getApkPackageName());
$this->assertEquals($score, $response->getScore());
- $this->assertEquals($action ?? '', $response->getAction());
+ $this->assertEquals($action, $response->getAction());
}
/**
- * @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
{
@@ -111,18 +109,6 @@ public static function provideJson(): array
'BAD JSON',
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,
- ],
- [
- '{"success": false, "error-codes": null}',
- false, [ReCaptcha::E_UNKNOWN_ERROR], null, null, null, null, null,
- ],
- [
- '{"success": false, "error-codes": 123}',
- false, [ReCaptcha::E_UNKNOWN_ERROR], null, null, null, null, null,
- ],
];
}
@@ -135,7 +121,7 @@ public function testIsSuccess(): void
$this->assertFalse($response->isSuccess());
$response = new Response(true, [], 'example.com');
- $this->assertEquals('example.com', $response->getHostname());
+ $this->assertEquals('example.com', $response->getHostName());
}
public function testGetErrorCodes(): void
@@ -156,11 +142,12 @@ public function testGetHostname(): void
public function testGetChallengeTs(): void
{
$timestamp = 'timestamp';
+ $errorCodes = [];
$response = new Response(true, [], 'hostname', $timestamp);
$this->assertEquals($timestamp, $response->getChallengeTs());
}
- public function testGetApkPackageName(): void
+ public function TestGetApkPackageName(): void
{
$apk = 'apk';
$response = new Response(true, [], 'hostname', 'timestamp', 'apk');