From 170aea57cea7a4a357d96ede4b2832c04fe3a956 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 29 Apr 2021 21:11:44 +0000 Subject: [PATCH 1/4] Upgrade to GitHub-native Dependabot --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7995edd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: daily + time: "09:00" + open-pull-requests-limit: 10 From 1c0a143fa85217dde84d8fb991f9851110b0b7aa Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 31 Oct 2021 17:44:55 -0300 Subject: [PATCH 2/4] Version 6.0 up and running. --- README.md | 115 +++-- composer.json | 18 +- config/captchavel.php | 16 +- resources/lang/en/validation.php | 7 + src/Captchavel.php | 132 +++--- src/CaptchavelFake.php | 65 ++- src/CaptchavelServiceProvider.php | 18 +- src/Facades/Captchavel.php | 18 +- src/Http/CheckScore.php | 48 +++ .../Middleware/ChecksCaptchavelStatus.php | 26 -- src/Http/Middleware/NormalizeInput.php | 24 ++ .../ValidatesRequestAndResponse.php | 86 ---- src/Http/Middleware/VerificationHelpers.php | 63 +++ src/Http/Middleware/VerifyReCaptchaV2.php | 64 +-- src/Http/Middleware/VerifyReCaptchaV3.php | 130 +++--- src/Http/ReCaptchaResponse.php | 234 +++++------ src/Http/ValidatesResponse.php | 73 ++++ src/ReCaptcha.php | 162 ++++++++ src/RequestMacro.php | 7 +- tests/CaptchavelFakeTest.php | 8 +- tests/CaptchavelTest.php | 186 ++++----- tests/CreatesFulfilledResponse.php | 40 ++ tests/HelperTest.php | 6 +- .../Middleware/ChallengeMiddlewareTest.php | 392 +++++++++++------- tests/Http/Middleware/ScoreMiddlewareTest.php | 270 ++++++------ .../Middleware/UsesRoutesWithMiddleware.php | 36 +- tests/Http/ReCaptchaResponseTest.php | 160 +++++-- tests/ReCaptchaMiddlewareHelperTest.php | 112 +++++ tests/RegistersPackage.php | 1 - tests/RequestMacroTest.php | 33 ++ 30 files changed, 1643 insertions(+), 907 deletions(-) create mode 100644 resources/lang/en/validation.php create mode 100644 src/Http/CheckScore.php delete mode 100644 src/Http/Middleware/ChecksCaptchavelStatus.php create mode 100644 src/Http/Middleware/NormalizeInput.php delete mode 100644 src/Http/Middleware/ValidatesRequestAndResponse.php create mode 100644 src/Http/Middleware/VerificationHelpers.php create mode 100644 src/Http/ValidatesResponse.php create mode 100644 src/ReCaptcha.php create mode 100644 tests/CreatesFulfilledResponse.php create mode 100644 tests/ReCaptchaMiddlewareHelperTest.php create mode 100644 tests/RequestMacroTest.php diff --git a/README.md b/README.md index 707d044..b754bcf 100644 --- a/README.md +++ b/README.md @@ -6,30 +6,18 @@ Integrate reCAPTCHA into your Laravel app better than the Big G itself! -It uses your Laravel HTTP Client and **HTTP/2**, making your app **fast**. You only need a couple of lines to integrate. - -## Table of Contents - -* [Requirements](#requirements) -* [Installation](#installation) -* [Set Up](#set-up) -* [Usage](#usage) -* [Frontend integration](#frontend-integration) -* [Advanced configuration](#advanced-configuration) -* [Testing with Captchavel](#testing-with-captchavel) -* [Security](#security) -* [License](#license) +It uses your Laravel HTTP Client **async HTTP/2**, making your app **fast**. You only need a couple of lines to integrate. ## Requirements -* Laravel 7.x, 8.x, or later -* PHP 7.4, 8.0 or later +* Laravel 8.x, or later +* PHP 8.0 or later > If you need support for old versions, consider sponsoring or donating. ## Installation -You can install the package via composer: +You can install the package via Composer: ```bash composer require darkghosthunter/captchavel @@ -65,29 +53,27 @@ Usage differs based on if you're using checkbox, invisible, or Android challenge ### Checkbox, invisible and Android challenges -After you integrate reCAPTCHA into your frontend or Android app, set the Captchavel middleware in the `POST` routes where a form with reCAPTCHA is submitted. The middleware will catch the `g-recaptcha-response` input and check if it's valid. +After you integrate reCAPTCHA into your frontend or Android app, set the Captchavel middleware in the `POST` routes where a form with reCAPTCHA is submitted. The middleware will catch the `g-recaptcha-response` input (you can change it later) and check if it's valid. * `recaptcha:checkbox` for explicitly rendered checkbox challenges. * `recaptcha:invisible` for invisible challenges. * `recaptcha:android` for Android app challenges. -When the validation fails, the user will be redirected back to the form route, or a JSON response will be returned with the validation errors. - ```php use App\Http\Controllers\Auth\LoginController; Route::post('login', [LoginController::class, 'login'])->middleware('recaptcha:checkbox'); -``` +``` + +When the validation fails, the user will be redirected back, or a JSON response will be returned with the validation errors. > You can change the input name from `g-recaptcha-response` to a custom using a second parameter, like `recaptcha.checkbox:my_input_name`. ### Score-driven challenge -The reCAPTCHA v3 middleware works differently from v2. This is a score-driven response is _always_ a success, but the challenge is scored between `0.0` and `1.0`. Robots will get lower scores, while human-like interaction will be higher. - -The default threshold is `0.5`, which will set apart robots that score less than that. +The reCAPTCHA v3 middleware works differently from v2. This is a score-driven response is _always_ a success, but the challenge scores between `0.0` and `1.0`. Human-like interaction will be higher, while robots will score lower. The default threshold is `0.5`, but this can be changed globally or per-route. -Simply add the `recaptcha.score` middleware to your route: +To start using it, simply add the `recaptcha.score` middleware to your route: ```php use App\Http\Controllers\CommentController; @@ -95,7 +81,7 @@ use App\Http\Controllers\CommentController; Route::post('comment', [CommentController::class, 'create'])->middleware('recaptcha.score'); ``` -Once the challenge has been received, you will have access to two methods from the Request instance: `isHuman()` and `isRobot()`, which return `true` or `false`: +Once the challenge has been received, you will have access to two methods from the Request class or instance: `isHuman()` and `isRobot()`, which return `true` or `false`: ```php public function store(Request $request, Post $post) @@ -115,6 +101,18 @@ public function store(Request $request, Post $post) } ``` +You can also have access to the response from reCAPTCHA using the `response()` method of the `Captchavel` facade: + +```php +use DarkGhostHunter\Captchavel\Facades\Captchavel; + +$response = Captchavel::response(); + +if ($response->score > 0.2) { + return 'Try again!'; +} +``` + #### Threshold, action and input name The middleware accepts three additional parameters in the following order: @@ -130,7 +128,28 @@ Route::post('comment', [CommentController::class, 'create']) ->middleware('recaptcha.score:0.7,login,custom-recaptcha-input'); ``` -> When checking the action name, ensure your Frontend action matches . +> When checking the action name, ensure your frontend action matches with the expected in the middleware. + +#### Bypassing on authenticated users + +Sometimes you may want to bypass reCAPTCHA checks on authenticated user, or automatically receive it as a "human" on score-driven challenges. While in your frontend you can programmatically disable reCAPTCHA when the user is authenticated, on the backend you can specify the guards to check as the last middleware parameters. + +Since having a lot of arguments on a middleware can quickly become spaghetti code, use the `ReCaptcha` helper to declare it using fluid methods. + +```php +use App\Http\Controllers\CommentController; +use App\Http\Controllers\MessageController; +use DarkGhostHunter\Captchavel\ReCaptcha; +use Illuminate\Support\Facades\Route + +Route::post('message/send', [MessageController::class, 'send']) + ->middleware(ReCaptcha::invisible()->except('user')->toString()); + +Route::post('comment/store', [CommentController::class, 'store']) + ->middleware(ReCaptcha::score(0.7)->action('comment.store')->except('admin', 'moderator')->toString()); +``` + +> Ensure you set the middleware as `->toString()` when using the helper to declare the middleware. #### Faking reCAPTCHA scores @@ -142,12 +161,14 @@ CAPTCHAVEL_FAKE=true This environment variable changes the reCAPTCHA Factory for a fake one, which will fake successful responses from reCAPTCHA, instead of resolving real challenges. -From there, you can fake a robot or human response by adding an `is_robot` and returning `true` or `false`, respectively. +From there, you can fake a robot response by filling the `is_robot` input in your form. ```blade
- + @env('local', 'testing') + + @endenv
``` @@ -172,7 +193,7 @@ You can use the `captchavel()` helper to output the site key depending on the ch ## Advanced configuration -Captchavel is intended to work out-of-the-box, but you can publish the configuration file for fine-tuning and additional reCAPTCHA verification. +Captchavel is intended to work out-of-the-box, but you can publish the configuration file for fine-tuning the reCAPTCHA verification. ```bash php artisan vendor:publish --provider="DarkGhostHunter\Captchavel\CaptchavelServiceProvider" --tag="config" @@ -183,13 +204,15 @@ You will get a config file with this array: ```php env('CAPTCHAVEL_ENABLE', false), - 'fake' => env('CAPTCHAVEL_FAKE', false), - 'hostname' => env('RECAPTCHA_HOSTNAME'), - 'apk_package_name' => env('RECAPTCHA_APK_PACKAGE_NAME'), - 'threshold' => 0.5, - 'credentials' => [ + 'enable' => env('CAPTCHAVEL_ENABLE', false), + 'fake' => env('CAPTCHAVEL_FAKE', false), + 'hostname' => env('RECAPTCHA_HOSTNAME'), + 'apk_package_name' => env('RECAPTCHA_APK_PACKAGE_NAME'), + 'threshold' => 0.5, + 'credentials' => [ // ... ] ]; @@ -205,15 +228,17 @@ return [ ]; ``` -By default, Captchavel is disabled, so it doesn't check reCAPTCHA challenges. You can forcefully enable it with the `CAPTCHAVEL_ENABLE` environment variable. +By default, Captchavel is disabled, so it doesn't check reCAPTCHA challenges, and on score-driven routes, it will always resolve as human interaction. + +You can forcefully enable it with the `CAPTCHAVEL_ENABLE` environment variable. ```dotenv CAPTCHAVEL_ENABLE=true ``` -This can be handy to enable on some local or development environments to check real interaction using the included localhost test keys, which only work on `localhost`. +This can be handy to enable on some local or development environments to check real interaction using the included _localhost_ test keys, which only work on `localhost`. -> When switched off, the reCAPTCHA challenge is not validated in the Request input, so you can safely disregard any frontend script or reCAPTCHA tokens or boxes. +> When switched off, the reCAPTCHA v2 challenges are not validated in the Request input, so you can safely disregard any frontend script or reCAPTCHA tokens or boxes. ### Fake responses @@ -223,7 +248,7 @@ CAPTCHAVEL_FAKE=true If Captchavel is [enabled](#enable-switch), setting this to true will allow your application to [fake v3-score responses from reCAPTCHA servers](#faking-recaptcha-scores). -> This is automatically set to `true` when [running unit tests](#testing-with-captchavel). +> This is automatically set to `true` when [running unit tests](#testing-score-with-captchavel). ### Hostname and APK Package Name @@ -232,7 +257,7 @@ RECAPTCHA_HOSTNAME=myapp.com RECAPTCHA_APK_PACKAGE_NAME=my.package.name ``` -If you are not verifying the Hostname or APK Package Name in your [reCAPTCHA Admin Panel](https://www.google.com/recaptcha/admin/), you will have to issue the strings in the environment file. +If you are not verifying the Hostname or APK Package Name in your [reCAPTCHA Admin Panel](https://www.google.com/recaptcha/admin/), you will have to issue them in the environment file. When the reCAPTCHA response from the servers is retrieved, it will be checked against these values when present. In case of mismatch, a validation exception will be thrown. @@ -244,7 +269,7 @@ return [ ]; ``` -Default threshold to check against reCAPTCHA v3 challenges. Values **equal or above** will be considered as human. +Default threshold to check against reCAPTCHA v3 challenges. Values **equal or above** will be considered "human". If you're not using reCAPTCHA v3, or you're fine with the default, leave this alone. You can still [override the default in a per-route basis](#threshold-action-and-input-name). @@ -264,15 +289,14 @@ Here is the full array of [reCAPTCHA credentials](#set-up) to use depending on t On testing, when Captchavel is disabled, routes set with the v2 middleware won't need to input the challenge in their body as it will be not verified. -On the other hand, reCAPTCHA v3 (score) responses can be [automatically faked](#fake-responses). +On the other hand, reCAPTCHA v3 (score) responses [are always faked](#fake-responses) as humans, even if [Captchavel is disabled](#enable-switch). This guarantees you can always access the response in your controller. -To do that, you should [enable Captchavel](#enable-switch) on your tests, through the `.env.testing` environment file, or in [PHPUnit environment section](https://phpunit.readthedocs.io/en/9.5/configuration.html?highlight=environment#the-env-element). If you use another testing framework, refer to its documentation. +To modify the score in your tests, you should [enable faking](#fake-responses) on your tests through the `.env.testing` environment file, or in [PHPUnit environment section](https://phpunit.readthedocs.io/en/9.5/configuration.html?highlight=environment#the-env-element). If you use another testing framework, refer to its documentation. ```xml - @@ -283,7 +307,6 @@ Alternatively, you can change the configuration before your unit test: ```php public function test_this_route() { - config()->set('captchavel.enable', true); config()->set('captchavel.fake', true); // ... @@ -316,7 +339,7 @@ $this->post('login', [ ])->assertViewIs('login.2fa'); ``` -> Fake responses don't come with actions, hostnames or APK package names. +> Fake responses don't come with actions, hostnames or APK package names, so these are not validated. ### Faking Scores manually diff --git a/composer.json b/composer.json index 75793eb..b5892bf 100644 --- a/composer.json +++ b/composer.json @@ -17,18 +17,18 @@ } ], "require": { - "php": ">=7.4", + "php": "^8.0", "ext-json": "*", - "illuminate/support": "^7.0||^8.0", - "illuminate/http": "^7.0||^8.0", - "illuminate/routing": "^7.0||^8.0", - "illuminate/container": "^7.0||^8.0", - "illuminate/events": "^7.0||^8.0", - "guzzlehttp/guzzle": "^6.0||^7.0" + "illuminate/support": "^8.0", + "illuminate/http": "^8.0", + "illuminate/routing": "^8.0", + "illuminate/container": "^8.0", + "illuminate/events": "^8.0", + "guzzlehttp/guzzle": "^7.4.0" }, "require-dev": { - "orchestra/testbench": "^5.18||^6.14.0", - "phpunit/phpunit": "^9.5.2" + "orchestra/testbench": "^6.22.0", + "phpunit/phpunit": "^9.5.10" }, "autoload": { "psr-4": { diff --git a/config/captchavel.php b/config/captchavel.php index 3030348..9bb36c4 100644 --- a/config/captchavel.php +++ b/config/captchavel.php @@ -9,7 +9,7 @@ | Main switch |-------------------------------------------------------------------------- | - | This switch enables the main Captchavel middleware that will detect all + | This switch enables the main Captchavel v2 middleware to detect all the | challenges incoming. You should activate it on production environments | and deactivate it on local environments unless you to test responses. | @@ -26,6 +26,8 @@ | servers in local development. To do this, simply enable the environment | variable and then issue as a checkbox parameter is_robot to any form. | + | For v2 middleware, faking means bypassing checks. + | */ 'fake' => env('CAPTCHAVEL_FAKE', false), @@ -49,9 +51,9 @@ | Threshold |-------------------------------------------------------------------------- | - | For reCAPTCHA v3, which is an score-driven interaction, this default + | For reCAPTCHA v3, which is a score-driven interaction, this default | threshold is the slicing point between bots and humans. If a score - | is below this threshold, it means the request was made by a bot. + | is below this threshold it means the request was made by a bot. | */ @@ -59,7 +61,7 @@ /* |-------------------------------------------------------------------------- - | Credenctials + | Credentials |-------------------------------------------------------------------------- | | The following is the array of credentials for each version and variant @@ -70,9 +72,9 @@ 'credentials' => [ Captchavel::CHECKBOX => [ - 'secret' => env('RECAPTCHA_CHECKBOX_SECRET', Captchavel::TEST_V2_SECRET), - 'key' => env('RECAPTCHA_CHECKBOX_KEY', Captchavel::TEST_V2_KEY), - ], + 'secret' => env('RECAPTCHA_CHECKBOX_SECRET', Captchavel::TEST_V2_SECRET), + 'key' => env('RECAPTCHA_CHECKBOX_KEY', Captchavel::TEST_V2_KEY), + ], Captchavel::INVISIBLE => [ 'secret' => env('RECAPTCHA_INVISIBLE_SECRET', Captchavel::TEST_V2_SECRET), 'key' => env('RECAPTCHA_INVISIBLE_KEY', Captchavel::TEST_V2_KEY), diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php new file mode 100644 index 0000000..0c73891 --- /dev/null +++ b/resources/lang/en/validation.php @@ -0,0 +1,7 @@ + 'The reCAPTCHA challenge does not matches this action.', + 'missing' => 'The reCAPTCHA challenge was not completed or is missing.', + 'error' => 'Error resolving the reCAPTCHA challenge: :errors.', +]; diff --git a/src/Captchavel.php b/src/Captchavel.php index 695f15f..8ac29f1 100644 --- a/src/Captchavel.php +++ b/src/Captchavel.php @@ -3,13 +3,14 @@ namespace DarkGhostHunter\Captchavel; use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; -use Illuminate\Container\Container; +use GuzzleHttp\Promise\PromiseInterface; use Illuminate\Contracts\Config\Repository; use Illuminate\Http\Client\Factory; -use Illuminate\Http\Client\Response; use LogicException; -use RuntimeException; +/** + * @internal + */ class Captchavel { // Constants to identify each reCAPTCHA service. @@ -47,18 +48,18 @@ class Captchavel public const INPUT = 'g-recaptcha-response'; /** - * Laravel HTTP Client factory. + * If Captchavel is enabled; * - * @var \Illuminate\Http\Client\Factory + * @var bool|mixed */ - protected Factory $http; + protected bool $enabled = false; /** - * Config Repository. + * If this should fake responses. * - * @var \Illuminate\Contracts\Config\Repository + * @var bool|mixed */ - protected Repository $config; + protected bool $fake = false; /** * Create a new Captchavel instance. @@ -66,90 +67,103 @@ class Captchavel * @param \Illuminate\Http\Client\Factory $http * @param \Illuminate\Contracts\Config\Repository $config */ - public function __construct(Factory $http, Repository $config) + public function __construct(protected Factory $http, protected Repository $config) { - $this->http = $http; - $this->config = $config; + $this->enabled = $this->config->get('captchavel.enable'); + $this->fake = $this->config->get('captchavel.fake'); } /** - * Resolves a reCAPTCHA challenge. - * - * @param string $challenge - * @param string $ip - * @param string $version + * Check if Captchavel is enabled. * - * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + * @return bool */ - public function getChallenge(string $challenge, string $ip, string $version): ReCaptchaResponse + public function isEnabled(): bool { - $response = $this->send($challenge, $ip, $this->useCredentials($version)) - ->setVersion($version) - ->setAsResolved(); - - Container::getInstance()->instance(ReCaptchaResponse::class, $response); - - return $response; + return $this->enabled; } /** - * Sets the correct credentials to use to retrieve the challenge results. - * - * @param string $mode + * Check if Captchavel is disabled. * - * @return string + * @return bool */ - protected function useCredentials(string $mode): string + public function isDisabled(): bool { - if (!in_array($mode, static::getModes())) { - throw new LogicException('The reCAPTCHA mode must be: ' . implode(', ', static::getModes())); - } + return !$this->isEnabled(); + } - if (! $key = $this->config->get("captchavel.credentials.{$mode}.secret")) { - throw new RuntimeException("The reCAPTCHA secret for [{$mode}] doesn't exists"); - } + /** + * Check if the reCAPTCHA response should be faked on-demand. + * + * @return bool + */ + public function shouldFake(): bool + { + return $this->fake; + } - return $key; + /** + * Returns the reCAPTCHA response. + * + * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + */ + public function response(): ReCaptchaResponse + { + return app(ReCaptchaResponse::class); } /** - * Retrieves the Response Challenge. + * Resolves a reCAPTCHA challenge. * - * @param string $challenge + * @param string|null $challenge * @param string $ip - * @param string $secret - * + * @param string $version + * @param string $input + * @param string|null $action * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse */ - protected function send(string $challenge, string $ip, string $secret): ReCaptchaResponse + public function getChallenge( + ?string $challenge, + string $ip, + string $version, + string $input, + string $action = null, + ): ReCaptchaResponse { - $response = $this->http - ->asForm() - ->withOptions(['version' => 2.0]) - ->post(static::RECAPTCHA_ENDPOINT, ['secret' => $secret, 'response' => $challenge, 'remoteip' => $ip]); - - return $this->parse($response); + return new ReCaptchaResponse($this->request($challenge, $ip, $version), $input, $action); } /** - * Parses the Response - * - * @param \Illuminate\Http\Client\Response $response + * Creates a Pending Request or a Promise. * - * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + * @param string $challenge + * @param string $ip + * @param string $version + * @return \GuzzleHttp\Promise\PromiseInterface<\Illuminate\Http\Client\Response> */ - protected function parse(Response $response): ReCaptchaResponse + protected function request(string $challenge, string $ip, string $version): PromiseInterface { - return new ReCaptchaResponse($response->json()); + return $this->http + ->asForm() + ->async() + ->withOptions(['version' => 2.0]) + ->post(static::RECAPTCHA_ENDPOINT, [ + 'secret' => $this->secret($version), + 'response' => $challenge, + 'remoteip' => $ip, + ]); } /** - * Checks if the mode is a valid mode name. + * Sets the correct credentials to use to retrieve the challenge results. * - * @return array|string[] + * @param string $version + * @return string */ - protected static function getModes(): array + protected function secret(string $version): string { - return [static::CHECKBOX, static::INVISIBLE, static::ANDROID, static::SCORE]; + return $this->config->get("captchavel.credentials.$version.secret") + ?? throw new LogicException("The reCAPTCHA secret for [$version] doesn't exists or is not set."); } } diff --git a/src/CaptchavelFake.php b/src/CaptchavelFake.php index 79b070e..ad589c5 100644 --- a/src/CaptchavelFake.php +++ b/src/CaptchavelFake.php @@ -3,7 +3,19 @@ namespace DarkGhostHunter\Captchavel; use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; +use DarkGhostHunter\Captchavel\Http\ReCaptchaV3Response; +use GuzzleHttp\Promise\FulfilledPromise; +use GuzzleHttp\Psr7\Response as GuzzleResponse; +use Illuminate\Http\Client\Response; +use function json_encode; +use function now; + +use const JSON_THROW_ON_ERROR; + +/** + * @internal + */ class CaptchavelFake extends Captchavel { /** @@ -14,26 +26,35 @@ class CaptchavelFake extends Captchavel public ?float $score = null; /** - * Resolves a reCAPTCHA challenge. - * - * @param string|null $challenge - * @param string $ip - * @param string $version - * - * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + * @inheritDoc */ - public function getChallenge(?string $challenge = null, string $ip, string $version): ReCaptchaResponse + public function getChallenge( + ?string $challenge, + string $ip, + string $version, + string $input, + string $action = null, + ): ReCaptchaResponse { - return (new ReCaptchaResponse( - [ - 'success' => true, - 'action' => null, - 'hostname' => null, - 'apk_package_name' => null, - 'challenge_ts' => now()->toAtomString(), - 'score' => $this->score, - ] - ))->setVersion(Captchavel::SCORE)->setAsResolved(); + return new ReCaptchaResponse( + new FulfilledPromise( + new Response( + new GuzzleResponse( + 200, + ['Content-type' => 'application/json'], + json_encode([ + 'success' => true, + 'action' => null, + 'hostname' => null, + 'apk_package_name' => null, + 'challenge_ts' => now()->toAtomString(), + 'score' => $this->score ?? 1.0, + ], JSON_THROW_ON_ERROR) + ) + ) + ), + $input, + ); } /** @@ -53,9 +74,9 @@ public function fakeScore(float $score): void * * @return void */ - public function fakeRobots(): void + public function fakeRobot(): void { - $this->score = 0; + $this->fakeScore(0); } /** @@ -63,8 +84,8 @@ public function fakeRobots(): void * * @return void */ - public function fakeHumans(): void + public function fakeHuman(): void { - $this->score = 1.0; + $this->fakeScore(1.0); } } diff --git a/src/CaptchavelServiceProvider.php b/src/CaptchavelServiceProvider.php index 4a81119..37c4cac 100644 --- a/src/CaptchavelServiceProvider.php +++ b/src/CaptchavelServiceProvider.php @@ -4,12 +4,15 @@ use DarkGhostHunter\Captchavel\Http\Middleware\VerifyReCaptchaV2; use DarkGhostHunter\Captchavel\Http\Middleware\VerifyReCaptchaV3; -use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; use Illuminate\Contracts\Config\Repository; +use Illuminate\Http\Client\Factory; use Illuminate\Http\Request; use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider; +/** + * @internal + */ class CaptchavelServiceProvider extends ServiceProvider { /** @@ -20,12 +23,11 @@ class CaptchavelServiceProvider extends ServiceProvider public function register() { $this->mergeConfigFrom(__DIR__.'/../config/captchavel.php', 'captchavel'); + $this->loadTranslationsFrom(__DIR__. '/../resources/lang', 'captchavel'); - // This is our new factory of reCAPTCHA responses. - $this->app->singleton(Captchavel::class); - - // Bind an empty response which by default is never resolved. - $this->app->singleton(ReCaptchaResponse::class); + $this->app->singleton(Captchavel::class, static function ($app): Captchavel { + return new Captchavel($app[Factory::class], $app['config']); + }); } /** @@ -45,8 +47,8 @@ public function boot(Router $router, Repository $config) } } - $router->aliasMiddleware('recaptcha', VerifyReCaptchaV2::class); - $router->aliasMiddleware('recaptcha.score', VerifyReCaptchaV3::class); + $router->aliasMiddleware(VerifyReCaptchaV2::SIGNATURE, VerifyReCaptchaV2::class); + $router->aliasMiddleware(VerifyReCaptchaV3::SIGNATURE, VerifyReCaptchaV3::class); Request::macro('isRobot', [RequestMacro::class, 'isRobot']); Request::macro('isHuman', [RequestMacro::class, 'isHuman']); diff --git a/src/Facades/Captchavel.php b/src/Facades/Captchavel.php index 860c40f..7432fa7 100644 --- a/src/Facades/Captchavel.php +++ b/src/Facades/Captchavel.php @@ -6,7 +6,9 @@ use Illuminate\Support\Facades\Facade; /** - * @method static \DarkGhostHunter\Captchavel\Captchavel getFacadeRoot() + * @method static \DarkGhostHunter\Captchavel\Captchavel|\DarkGhostHunter\Captchavel\CaptchavelFake getFacadeRoot() + * @method static bool isEnabled() + * @method static \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse response() */ class Captchavel extends Facade { @@ -33,9 +35,9 @@ public static function fake(): CaptchavelFake return $instance; } - static::swap($fake = static::getFacadeApplication()->make(CaptchavelFake::class)); + static::swap($instance = static::getFacadeApplication()->make(CaptchavelFake::class)); - return $fake; + return $instance; } /** @@ -45,7 +47,7 @@ public static function fake(): CaptchavelFake * * @return void */ - public static function fakeScore(float $score) + public static function fakeScore(float $score): void { static::fake()->fakeScore($score); } @@ -55,9 +57,9 @@ public static function fakeScore(float $score) * * @return void */ - public static function fakeRobot() + public static function fakeRobot(): void { - static::fake()->fakeRobots(); + static::fake()->fakeRobot(); } /** @@ -65,8 +67,8 @@ public static function fakeRobot() * * @return void */ - public static function fakeHuman() + public static function fakeHuman(): void { - static::fake()->fakeHumans(); + static::fake()->fakeHuman(); } } diff --git a/src/Http/CheckScore.php b/src/Http/CheckScore.php new file mode 100644 index 0000000..e9160e1 --- /dev/null +++ b/src/Http/CheckScore.php @@ -0,0 +1,48 @@ +threshold = $threshold; + + return $this; + } + + /** + * Check if the request was made by a human. + * + * @return bool If the response is V2, this always returns false. + */ + public function isHuman(): bool + { + return $this->get('score', 1.0) >= $this->threshold; + } + + /** + * Check if the request was made by a robot. + * + * @return bool If the response is V2, this always returns false. + */ + public function isRobot(): bool + { + return ! $this->isHuman(); + } +} diff --git a/src/Http/Middleware/ChecksCaptchavelStatus.php b/src/Http/Middleware/ChecksCaptchavelStatus.php deleted file mode 100644 index 1ebd5ee..0000000 --- a/src/Http/Middleware/ChecksCaptchavelStatus.php +++ /dev/null @@ -1,26 +0,0 @@ -config->get('captchavel.enable'); - } - - /** - * Check if the reCAPTCHA response should be faked on-demand. - * - * @return bool - */ - protected function isFake(): bool - { - return $this->config->get('captchavel.fake'); - } -} diff --git a/src/Http/Middleware/NormalizeInput.php b/src/Http/Middleware/NormalizeInput.php new file mode 100644 index 0000000..e4162a8 --- /dev/null +++ b/src/Http/Middleware/NormalizeInput.php @@ -0,0 +1,24 @@ +input($input); - - if (!is_string($value) || blank($value)) { - throw $this->validationException($input, 'The reCAPTCHA challenge is missing or has not been completed.'); - } - } - - /** - * Creates a new Validation Exception instance. - * - * @param string $input - * @param string $message - * - * @return \Illuminate\Validation\ValidationException - */ - protected function validationException(string $input, string $message): ValidationException - { - return ValidationException::withMessages([$input => trans($message)])->redirectTo(back()->getTargetUrl()); - } - - - /** - * Validate the Hostname and APK name from the response. - * - * @param \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse $response - * @param string $input - * @param string|null $action - * - * @throws \Illuminate\Validation\ValidationException - */ - protected function validateResponse( - ReCaptchaResponse $response, - $input = Captchavel::INPUT, - ?string $action = null - ): void { - if ($response->isDifferentHostname($this->config->get('captchavel.hostname'))) { - throw $this->validationException( - 'hostname', - "The hostname [{$response->hostname}] of the response is invalid." - ); - } - - if ($response->isDifferentApk($this->config->get('captchavel.apk_package_name'))) { - throw $this->validationException( - 'apk_package_name', - "The apk_package_name [{$response->apk_package_name}] of the response is invalid." - ); - } - - if ($response->isDifferentAction($action)) { - throw $this->validationException( - 'action', - "The action [{$response->action}] of the response is invalid." - ); - } - - if ($response->isInvalid()) { - throw $this->validationException( - $input, - "The reCAPTCHA challenge is invalid or was not completed." - ); - } - } -} diff --git a/src/Http/Middleware/VerificationHelpers.php b/src/Http/Middleware/VerificationHelpers.php new file mode 100644 index 0000000..aa2f1f8 --- /dev/null +++ b/src/Http/Middleware/VerificationHelpers.php @@ -0,0 +1,63 @@ +guard($guard)->check()) { + return false; + } + } + + return true; + } + + /** + * Checks if the user is authenticated on the given guards. + * + * @param array $guards + * @return bool + */ + protected function isAuth(array $guards): bool + { + return ! $this->isGuest($guards); + } + + /** + * Validate if this Request has the reCAPTCHA challenge string. + * + * @param \Illuminate\Http\Request $request + * @param string $input + * @return void + * @throws \Illuminate\Validation\ValidationException + */ + protected function ensureChallengeIsPresent(Request $request, string $input): void + { + if ($request->missing($input)) { + throw ValidationException::withMessages([ + $input => trans('captchavel::validation.missing') + ])->redirectTo(back()->getTargetUrl()); + } + } +} diff --git a/src/Http/Middleware/VerifyReCaptchaV2.php b/src/Http/Middleware/VerifyReCaptchaV2.php index 93ec60d..2e8b1db 100644 --- a/src/Http/Middleware/VerifyReCaptchaV2.php +++ b/src/Http/Middleware/VerifyReCaptchaV2.php @@ -4,39 +4,26 @@ use Closure; use DarkGhostHunter\Captchavel\Captchavel; -use Illuminate\Config\Repository; +use DarkGhostHunter\Captchavel\Facades\Captchavel as CaptchavelFacade; +use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; +use Illuminate\Container\Container; use Illuminate\Http\Request; +use LogicException; +/** + * @internal + */ class VerifyReCaptchaV2 { - use ChecksCaptchavelStatus; - use ValidatesRequestAndResponse; + use VerificationHelpers; + use NormalizeInput; /** - * Captchavel connector. + * The signature of the middleware. * - * @var \DarkGhostHunter\Captchavel\Captchavel|\DarkGhostHunter\Captchavel\CaptchavelFake + * @var string */ - protected Captchavel $captchavel; - - /** - * Application Config repository. - * - * @var \Illuminate\Config\Repository - */ - protected Repository $config; - - /** - * BaseReCaptchaMiddleware constructor. - * - * @param \DarkGhostHunter\Captchavel\Captchavel $captchavel - * @param \Illuminate\Config\Repository $config - */ - public function __construct(Captchavel $captchavel, Repository $config) - { - $this->config = $config; - $this->captchavel = $captchavel; - } + public const SIGNATURE = 'recaptcha'; /** * Handle the incoming request. @@ -45,17 +32,30 @@ public function __construct(Captchavel $captchavel, Repository $config) * @param \Closure $next * @param string $version * @param string $input - * + * @param string ...$guards * @return mixed * @throws \Illuminate\Validation\ValidationException */ - public function handle(Request $request, Closure $next, string $version, string $input = Captchavel::INPUT) + public function handle( + Request $request, + Closure $next, + string $version, + string $input = Captchavel::INPUT, + string ...$guards + ): mixed { - if ($this->isEnabled() && !$this->isFake()) { - $this->validateRequest($request, $input); - $this->validateResponse( - $this->captchavel->getChallenge($request->input($input), $request->ip(), $version), - $input + if ($version === Captchavel::SCORE) { + throw new LogicException('Use the [recaptcha.score] middleware to capture score-driven reCAPTCHA.'); + } + + $captchavel = CaptchavelFacade::getFacadeRoot(); + + if ($this->isGuest($guards) && $captchavel->isEnabled() && !$captchavel->shouldFake()) { + $this->ensureChallengeIsPresent($request, $input = $this->normalizeInput($input)); + + Container::getInstance()->instance( + ReCaptchaResponse::class, + $captchavel->getChallenge($request->input($input), $request->ip(), $version, $input)->wait() ); } diff --git a/src/Http/Middleware/VerifyReCaptchaV3.php b/src/Http/Middleware/VerifyReCaptchaV3.php index 5b3f327..ef07a5d 100644 --- a/src/Http/Middleware/VerifyReCaptchaV3.php +++ b/src/Http/Middleware/VerifyReCaptchaV3.php @@ -4,43 +4,35 @@ use Closure; use DarkGhostHunter\Captchavel\Captchavel; -use DarkGhostHunter\Captchavel\CaptchavelFake; use DarkGhostHunter\Captchavel\Facades\Captchavel as CaptchavelFacade; use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; -use Illuminate\Config\Repository; use Illuminate\Container\Container; use Illuminate\Http\Request; +use function app; +use function config; + +/** + * @internal + */ class VerifyReCaptchaV3 { - use ChecksCaptchavelStatus; - use ValidatesRequestAndResponse; - - /** - * Captchavel connector. - * - * @var \DarkGhostHunter\Captchavel\Captchavel|\DarkGhostHunter\Captchavel\CaptchavelFake - */ - protected Captchavel $captchavel; + use VerificationHelpers; + use NormalizeInput; /** - * Application Config repository. + * The signature of the middleware. * - * @var \Illuminate\Config\Repository + * @var string */ - protected Repository $config; + public const SIGNATURE = 'recaptcha.score'; /** - * BaseReCaptchaMiddleware constructor. + * Captchavel connector. * - * @param \DarkGhostHunter\Captchavel\Captchavel $captchavel - * @param \Illuminate\Config\Repository $config + * @var \DarkGhostHunter\Captchavel\Captchavel */ - public function __construct(Captchavel $captchavel, Repository $config) - { - $this->config = $config; - $this->captchavel = $captchavel; - } + protected Captchavel $captchavel; /** * Handle the incoming request. @@ -50,27 +42,31 @@ public function __construct(Captchavel $captchavel, Repository $config) * @param string|null $threshold * @param string|null $action * @param string $input - * + * @param string ...$guards * @return mixed * @throws \Illuminate\Validation\ValidationException */ - public function handle(Request $request, + public function handle( + Request $request, Closure $next, string $threshold = null, string $action = null, - string $input = Captchavel::INPUT - ) - { - if ($this->isEnabled()) { - if ($this->isFake()) { - $this->fakeResponseScore($request); - } else { - $this->validateRequest($request, $input); - } - - $this->processChallenge($request, $input, $threshold, $action); + string $input = Captchavel::INPUT, + string ...$guards, + ): mixed { + $this->captchavel = CaptchavelFacade::getFacadeRoot(); + + $input = $this->normalizeInput($input); + + // Ensure responses are always faked as humans, unless disabled and real. + if ($this->isAuth($guards) || ($this->captchavel->isDisabled() || $this->captchavel->shouldFake())) { + $this->fakeResponseScore($request); + } else { + $this->ensureChallengeIsPresent($request, $input); } + $this->process($this->response($request, $input, $action), $threshold); + return $next($request); } @@ -78,57 +74,57 @@ public function handle(Request $request, * Fakes a score reCAPTCHA response. * * @param \Illuminate\Http\Request $request - * * @return void */ protected function fakeResponseScore(Request $request): void { - if (! $this->captchavel instanceof CaptchavelFake) { - $this->captchavel = CaptchavelFacade::fake(); - } + // Swap the implementation to the Captchavel Fake. + $this->captchavel = CaptchavelFacade::fake(); - // If the Captchavel has set an score to fake, use it, otherwise go default. - if ($this->captchavel->score === null) { - $request->filled('is_robot') ? $this->captchavel->fakeRobots() : $this->captchavel->fakeHumans(); + // If we're faking scores, allow the user to fake it through the input. + if ($this->captchavel->shouldFake()) { + $this->captchavel->score ??= (float) $request->missing('is_robot'); } } /** - * Process the response from reCAPTCHA servers. + * Retrieves the response, still being a promise pending resolution. * * @param \Illuminate\Http\Request $request * @param string $input - * @param null|string $threshold - * @param null|string $action - * - * @throws \Illuminate\Validation\ValidationException + * @param string|null $action + * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse */ - protected function processChallenge(Request $request, string $input, ?string $threshold, ?string $action) + protected function response(Request $request, string $input, ?string $action): ReCaptchaResponse { - $response = $this->captchavel->getChallenge( - $request->input($input), - $request->ip(), - Captchavel::SCORE - )->setThreshold($this->normalizeThreshold($threshold)); + return $this->captchavel->getChallenge( + $request->input($input), $request->ip(), Captchavel::SCORE, $input, $this->normalizeAction($action) + ); + } - $this->validateResponse($response, $input, $this->normalizeAction($action)); + /** + * Process the response from reCAPTCHA servers. + * + * @param \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse $response + * @param null|string $threshold + * @return void + */ + protected function process(ReCaptchaResponse $response, ?string $threshold): void + { + $response->setThreshold($this->normalizeThreshold($threshold)); - // After we get the response, we will register the instance as a shared - // "singleton" for the current request lifetime. Obviously we will set - // the threshold set by the developer or just use the config default. Container::getInstance()->instance(ReCaptchaResponse::class, $response); } /** - * Normalize the threshold string. + * Normalize the threshold string, or returns the default. * * @param string|null $threshold - * * @return float */ protected function normalizeThreshold(?string $threshold): float { - return $threshold === 'null' ? $this->config->get('captchavel.threshold') : (float)$threshold; + return strtolower($threshold) === 'null' ? config('captchavel.threshold') : (float) $threshold; } /** @@ -138,8 +134,20 @@ protected function normalizeThreshold(?string $threshold): float * * @return null|string */ - protected function normalizeAction(?string $action) : ?string + protected function normalizeAction(?string $action): ?string { return strtolower($action) === 'null' ? null : $action; } + + /** + * Handle tasks after the response has been sent to the browser. + * + * @return void + */ + public function terminate(): void + { + if (app()->has(ReCaptchaResponse::class)) { + app(ReCaptchaResponse::class)->terminate(); + } + } } diff --git a/src/Http/ReCaptchaResponse.php b/src/Http/ReCaptchaResponse.php index c645041..5cb20fb 100644 --- a/src/Http/ReCaptchaResponse.php +++ b/src/Http/ReCaptchaResponse.php @@ -2,237 +2,217 @@ namespace DarkGhostHunter\Captchavel\Http; -use DarkGhostHunter\Captchavel\Captchavel; -use Illuminate\Support\Fluent; -use RuntimeException; +use GuzzleHttp\Promise\PromiseInterface; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Contracts\Support\Jsonable; +use Illuminate\Http\Client\Response; +use Illuminate\Support\Carbon; +use JsonSerializable; + +use function array_key_exists; +use function json_encode; +use function value; + +use const JSON_THROW_ON_ERROR; /** - * @property-read null|string $hostname - * @property-read null|string $challenge_ts - * @property-read null|string $apk_package_name - * @property-read null|float $score - * @property-read null|string $action - * @property-read array $error_codes * @property-read bool $success + * @property-read string $hostname + * @property-read string $challenge_ts + * @property-read string $apk_package_name + * @property-read string $action + * @property-read float $score + * @property-read array $error_codes */ -class ReCaptchaResponse extends Fluent +class ReCaptchaResponse implements JsonSerializable, Arrayable, Jsonable { - /** - * Default reCAPTCHA version. - * - * @var string|null - */ - public ?string $version = null; + use CheckScore; + use ValidatesResponse; /** - * The threshold for reCAPTCHA v3. + * The data from the reCAPTCHA response. * - * @var float + * @var array */ - protected float $threshold = 1.0; + protected array $attributes = []; /** - * Check if the response from reCAPTCHA servers has been received. + * Creates a new reCAPTCHA Response Container. * - * @var mixed + * @param \GuzzleHttp\Promise\PromiseInterface $promise + * @param string $input + * @param string|null $expectedAction */ - protected bool $resolved = false; - - /** - * Sets the threshold to check the response. - * - * @param float $threshold - * @return $this - */ - public function setThreshold(float $threshold): ReCaptchaResponse + public function __construct( + protected PromiseInterface $promise, + protected string $input, + protected ?string $expectedAction = null + ) { - $this->threshold = $threshold; - - return $this; + $this->promise = $this->promise->then(function (Response $response): void { + $this->attributes = $response->json(); + $this->validate(); + }); } /** - * Sets the reCAPTCHA response as resolved. + * Checks if the response has been resolved. * - * @return $this + * @return bool */ - public function setAsResolved(): ReCaptchaResponse + public function isResolved(): bool { - $this->resolved = true; - - return $this; + return $this->promise->getState() === PromiseInterface::FULFILLED; } /** - * Check if the reCAPTCHA response has been resolved. + * Checks if the response has yet to be resolved. * * @return bool */ - public function isResolved(): bool + public function isPending(): bool { - return $this->resolved; + return ! $this->isResolved(); } /** - * Check if the reCAPTCHA response has not been resolved for the request. + * Returns the timestamp of the challenge as a Carbon instance. * - * @return bool + * @return \Illuminate\Support\Carbon */ - public function isNotResolved():bool + public function carbon(): Carbon { - return ! $this->isResolved(); + return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->get('challenge_ts')); } /** - * Returns if the response was made by a Human. + * Waits for this reCAPTCHA to be resolved. * - * @throws \LogicException - * @return bool + * @return $this */ - public function isHuman(): bool + public function wait(): static { - if ($this->isNotResolved()) { - throw new RuntimeException('There is no reCAPTCHA v3 response resolved for this request'); - } - - if ($this->version !== Captchavel::SCORE) { - throw new RuntimeException('This is not a reCAPTCHA v3 response'); - } - - if ($this->score === null) { - throw new RuntimeException('This is reCAPTCHA v3 response has no score'); - } + $this->promise->wait(); - return $this->score >= $this->threshold; + return $this; } /** - * Returns if the response was made by a Robot. + * Terminates the reCAPTCHA response if still pending. * - * @return bool + * @return void */ - public function isRobot(): bool + public function terminate(): void { - return ! $this->isHuman(); + $this->promise->cancel(); } /** - * Returns if the challenge is valid. + * Returns the raw attributes of the response, bypassing the promise resolving. * - * @return bool + * @return array */ - public function isValid(): bool + public function getAttributes(): array { - return $this->success && empty($this->error_codes); + return $this->attributes; } /** - * Returns if the challenge is invalid. + * Get an attribute from the instance. * - * @return bool + * @param string $key + * @param mixed $default + * @return mixed */ - public function isInvalid(): bool + public function get(string $key, mixed $default = null): mixed { - return ! $this->isValid(); + $this->wait(); + + return $this->attributes[$key] ?? value($default); } /** - * Check if the hostname is different to the one issued. + * Convert the instance to an array. * - * @param string|null $string - * @return bool + * @return array */ - public function isDifferentHostname(?string $string): bool + public function toArray(): array { - return $string && $this->hostname !== $string; + $this->wait(); + + return $this->getAttributes(); } /** - * Check if the APK name is different to the one issued. + * Convert the object into something JSON serializable. * - * @param string|null $string - * @return bool + * @return array */ - public function isDifferentApk(?string $string): bool + public function jsonSerialize(): array { - return $string && $this->apk_package_name !== $string; + return $this->toArray(); } /** - * Check if the action name is different to the one issued. + * Convert the instance to JSON. * - * @param null|string $action - * @return bool + * @param int $options + * @return string + * @throws \JsonException */ - public function isDifferentAction(?string $action): bool + public function toJson($options = 0): string { - return $action && $this->action !== $action; + return json_encode($this->jsonSerialize(), JSON_THROW_ON_ERROR | $options); } /** - * Dynamically return an attribute as a property. + * Dynamically retrieve the value of an attribute. * - * @param $key + * @param string $key * @return mixed */ - public function __get($key) + public function __get(string $key): mixed { - // Minor fix for getting the error codes - return parent::__get($key === 'error_codes' ? 'error-codes' : $key); + return $this->get($key); } /** - * Sets the version for this reCAPTCHA response. - * - * @param string $version + * Dynamically set the value of an attribute. * - * @return $this + * @param string $key + * @param mixed $value + * @return void */ - public function setVersion(string $version): ReCaptchaResponse + public function __set(string $key, mixed $value): void { - $this->version = $version; + $this->wait(); - return $this; + $this->attributes[$key] = $value; } /** - * Checks if the reCAPTCHA challenge is for a given version. + * Dynamically check if an attribute is set. * + * @param string $key * @return bool */ - public function isCheckbox(): bool + public function __isset(string $key): bool { - return $this->version === Captchavel::CHECKBOX; - } + $this->wait(); - /** - * Checks if the reCAPTCHA challenge is for a given version. - * - * @return bool - */ - public function isInvisible(): bool - { - return $this->version === Captchavel::INVISIBLE; + return array_key_exists($key, $this->attributes); } /** - * Checks if the reCAPTCHA challenge is for a given version. + * Dynamically unset an attribute. * - * @return bool + * @param string $key + * @return void */ - public function isAndroid(): bool + public function __unset(string $key): void { - return $this->version === Captchavel::ANDROID; - } + $this->wait(); - /** - * Checks if the reCAPTCHA challenge is for a given version. - * - * @return bool - */ - public function isScore(): bool - { - return $this->version === Captchavel::SCORE; + unset($this->attributes[$key]); } - } diff --git a/src/Http/ValidatesResponse.php b/src/Http/ValidatesResponse.php new file mode 100644 index 0000000..295f2b6 --- /dev/null +++ b/src/Http/ValidatesResponse.php @@ -0,0 +1,73 @@ +attributes, 'success') !== true) { + throw $this->validationException([ + $this->input => trans('captchavel::validation.error', [ + 'errors' => implode(', ', Arr::wrap($this->attributes['errors'] ?? [])) + ]) + ]); + } + + foreach ($this->expectations() as $key => $value) { + $expectation = $this->attributes[$key] ?? null; + + if ($expectation !== '' && $expectation !== $value) { + $errors[$key] = trans('captchavel::validation.match'); + } + } + + if (!empty($errors)) { + throw $this->validationException([$this->input => $errors]); + } + } + + /** + * Creates a new validation exceptions with messages. + * + * @param array $messages + * @return \Illuminate\Validation\ValidationException + */ + protected function validationException(array $messages): ValidationException + { + return ValidationException::withMessages($messages)->redirectTo(back()->getTargetUrl()); + } + + /** + * Retrieve the expectations for the current response. + * + * @return array + * @internal + */ + protected function expectations(): array + { + return array_filter( + Arr::only(config('captchavel'), ['hostname', 'apk_package_name']) + + ['action' => $this->expectedAction] + ); + } +} diff --git a/src/ReCaptcha.php b/src/ReCaptcha.php new file mode 100644 index 0000000..cdf5d37 --- /dev/null +++ b/src/ReCaptcha.php @@ -0,0 +1,162 @@ +threshold($threshold ?? config('captchavel.threshold', 0.5)); + } + + /** + * Sets the input for the reCAPTCHA challenge on this route. + * + * @param string $name + * @return $this + */ + public function input(string $name): static + { + $this->input = $name; + + return $this; + } + + /** + * Bypass the check on users authenticated in the given guards. + * + * @param string ...$guards + * @return $this + */ + public function except(string ...$guards): static + { + $this->guards = $guards; + + return $this; + } + + /** + * Sets the threshold for the score-driven challenge. + * + * @param float $threshold + * @return $this + */ + public function threshold(float $threshold): static + { + if ($this->version !== Captchavel::SCORE) { + throw new LogicException("You cannot set [threshold] for a [$this->version] middleware."); + } + + $this->threshold = number_format(max(0, min(1, $threshold)), 1); + + return $this; + } + + /** + * Sets the action for the + * + * @param string $action + * @return $this + */ + public function action(string $action): static + { + if ($this->version !== Captchavel::SCORE) { + throw new LogicException("You cannot set [action] for a [$this->version] middleware."); + } + + $this->action = $action; + + return $this; + } + + /** + * Transforms the middleware helper into a string. + * + * @return string + */ + public function toString(): string + { + return $this->__toString(); + } + + /** + * Returns the string representation of the instance. + * + * @return string + */ + public function __toString(): string + { + $string = $this->version === Captchavel::SCORE + ? VerifyReCaptchaV3::SIGNATURE . ':' . implode(',', [$this->threshold, $this->action]) + : VerifyReCaptchaV2::SIGNATURE . ':' . $this->version; + + return rtrim($string . ',' . implode(',', [$this->input, implode(',', $this->guards)]), ','); + } +} diff --git a/src/RequestMacro.php b/src/RequestMacro.php index c7d9edf..b10d458 100644 --- a/src/RequestMacro.php +++ b/src/RequestMacro.php @@ -4,6 +4,11 @@ use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; +use function app; + +/** + * @internal + */ class RequestMacro { /** @@ -23,6 +28,6 @@ public static function isHuman(): bool */ public static function isRobot(): bool { - return ! static::isHuman(); + return !static::isHuman(); } } diff --git a/tests/CaptchavelFakeTest.php b/tests/CaptchavelFakeTest.php index a5c4449..cdfcc8e 100644 --- a/tests/CaptchavelFakeTest.php +++ b/tests/CaptchavelFakeTest.php @@ -20,12 +20,12 @@ protected function setUp() : void parent::setUp(); } - public function test_using_fake_on_unit_test() + public function test_using_fake_on_unit_test(): void { static::assertTrue(config('captchavel.fake')); } - public function test_makes_fake_score() + public function test_makes_fake_score(): void { Captchavel::fakeScore(0.3); @@ -36,7 +36,7 @@ public function test_makes_fake_score() $this->post('test')->assertOk()->assertExactJson([0.3, true, false]); } - public function test_makes_human_score_one() + public function test_makes_human_score_one(): void { Captchavel::fakeHuman(); @@ -47,7 +47,7 @@ public function test_makes_human_score_one() $this->post('test')->assertOk()->assertExactJson([1.0, false, true]); } - public function test_makes_robot_score_zero() + public function test_makes_robot_score_zero(): void { Captchavel::fakeRobot(); diff --git a/tests/CaptchavelTest.php b/tests/CaptchavelTest.php index cec4b58..6d88990 100644 --- a/tests/CaptchavelTest.php +++ b/tests/CaptchavelTest.php @@ -4,85 +4,105 @@ namespace Tests; use DarkGhostHunter\Captchavel\Captchavel; +use DarkGhostHunter\Captchavel\Facades\Captchavel as CaptchavelFacade; use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; -use GuzzleHttp\Psr7\Response as GuzzleResponse; use Illuminate\Http\Client\Factory; -use Illuminate\Http\Client\Response; use LogicException; use Mockery; use Orchestra\Testbench\TestCase; -use RuntimeException; + +use function app; class CaptchavelTest extends TestCase { use RegistersPackage; + use CreatesFulfilledResponse; - public function test_uses_v2_test_credentials_by_default() + public function test_returns_response() + { + $mock = $this->mock(Factory::class); + + $mock->shouldReceive('asForm')->withNoArgs()->once()->andReturnSelf(); + $mock->shouldReceive('async')->withNoArgs()->once()->andReturnSelf(); + $mock->shouldReceive('withOptions')->with(['version' => 2.0])->once()->andReturnSelf(); + $mock->shouldReceive('post') + ->with( + Captchavel::RECAPTCHA_ENDPOINT, + [ + 'secret' => Captchavel::TEST_V2_SECRET, + 'response' => 'token', + 'remoteip' => '127.0.0.1', + ] + ) + ->once() + ->andReturn( + $this->fulfilledPromise([ + 'success' => true, + 'foo' => 'bar', + ]) + ); + + $instance = app(Captchavel::class)->getChallenge('token', '127.0.0.1', 'checkbox', Captchavel::INPUT); + + $this->app->instance(ReCaptchaResponse::class, $instance); + + static::assertSame($instance, CaptchavelFacade::response()); + } + + public function test_uses_v2_test_credentials_by_default(): void { $mock = $this->mock(Factory::class); $mock->shouldReceive('asForm')->withNoArgs()->times(3)->andReturnSelf(); + $mock->shouldReceive('async')->withNoArgs()->times(3)->andReturnSelf(); $mock->shouldReceive('withOptions')->with(['version' => 2.0])->times(3)->andReturnSelf(); $mock->shouldReceive('post') ->with( Captchavel::RECAPTCHA_ENDPOINT, [ - 'secret' => Captchavel::TEST_V2_SECRET, + 'secret' => Captchavel::TEST_V2_SECRET, 'response' => 'token', 'remoteip' => '127.0.0.1', ] ) ->times(3) ->andReturn( - new Response( - new GuzzleResponse( - 200, ['Content-type' => 'application/json'], json_encode( - $array = [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ) - ) - ) + $this->fulfilledPromise([ + 'success' => true, + 'foo' => 'bar', + ]) ); /** @var \DarkGhostHunter\Captchavel\Captchavel $instance */ $instance = app(Captchavel::class); - $checkbox = $instance->getChallenge('token', '127.0.0.1', 'checkbox'); + $checkbox = $instance->getChallenge('token', '127.0.0.1', 'checkbox', Captchavel::INPUT); - static::assertTrue($checkbox->isResolved()); - static::assertSame($checkbox->version, 'checkbox'); static::assertTrue($checkbox->success); - static::assertSame(0.5, $checkbox->score); + static::assertNull($checkbox->score); static::assertSame('bar', $checkbox->foo); - $invisible = $instance->getChallenge('token', '127.0.0.1', 'invisible'); + $invisible = $instance->getChallenge('token', '127.0.0.1', 'invisible', Captchavel::INPUT); - static::assertTrue($invisible->isResolved()); - static::assertSame($invisible->version, 'invisible'); static::assertTrue($invisible->success); - static::assertSame(0.5, $invisible->score); + static::assertNull($checkbox->score); static::assertSame('bar', $invisible->foo); - $android = $instance->getChallenge('token', '127.0.0.1', 'android'); + $android = $instance->getChallenge('token', '127.0.0.1', 'android', Captchavel::INPUT); - static::assertTrue($android->isResolved()); - static::assertSame($android->version, 'android'); static::assertTrue($android->success); - static::assertSame(0.5, $android->score); + static::assertNull($checkbox->score); static::assertSame('bar', $android->foo); } - public function test_uses_v2_custom_credentials() + public function test_uses_v2_custom_credentials(): void { config( [ 'captchavel.credentials' => [ - 'checkbox' => ['secret' => 'secret-checkbox'], + 'checkbox' => ['secret' => 'secret-checkbox'], 'invisible' => ['secret' => 'secret-invisible'], - 'android' => ['secret' => 'secret-android'], + 'android' => ['secret' => 'secret-android'], ], ] ); @@ -90,125 +110,100 @@ public function test_uses_v2_custom_credentials() $mock = $this->mock(Factory::class); $mock->shouldReceive('asForm')->withNoArgs()->times(3)->andReturnSelf(); + $mock->shouldReceive('async')->withNoArgs()->times(3)->andReturnSelf(); $mock->shouldReceive('withOptions')->with(['version' => 2.0])->times(3)->andReturnSelf(); $mock->shouldReceive('post') ->with( Captchavel::RECAPTCHA_ENDPOINT, [ - 'secret' => 'secret-checkbox', + 'secret' => 'secret-checkbox', 'response' => 'token', 'remoteip' => '127.0.0.1', ] ) ->once() ->andReturn( - new Response( - new GuzzleResponse( - 200, ['Content-type' => 'application/json'], json_encode( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ) - ) - ) + $this->fulfilledPromise([ + 'success' => true, + 'foo' => 'bar', + ]) ); $mock->shouldReceive('post') ->with( Captchavel::RECAPTCHA_ENDPOINT, [ - 'secret' => 'secret-invisible', + 'secret' => 'secret-invisible', 'response' => 'token', 'remoteip' => '127.0.0.1', ] ) ->once() ->andReturn( - new Response( - new GuzzleResponse( - 200, ['Content-type' => 'application/json'], json_encode( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ) - ) - ) + $this->fulfilledPromise([ + 'success' => true, + 'foo' => 'bar', + ]) ); $mock->shouldReceive('post') ->with( Captchavel::RECAPTCHA_ENDPOINT, [ - 'secret' => 'secret-android', + 'secret' => 'secret-android', 'response' => 'token', 'remoteip' => '127.0.0.1', ] ) ->once() ->andReturn( - new Response( - new GuzzleResponse( - 200, ['Content-type' => 'application/json'], json_encode( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ) - ) - ) + $this->fulfilledPromise([ + 'success' => true, + 'foo' => 'bar', + ]) ); $instance = app(Captchavel::class); static::assertEquals( - Captchavel::CHECKBOX, - $instance->getChallenge('token', '127.0.0.1', 'checkbox')->version + 'bar', + $instance->getChallenge('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->foo ); static::assertEquals( - Captchavel::INVISIBLE, - $instance->getChallenge('token', '127.0.0.1', 'invisible')->version + 'bar', + $instance->getChallenge('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->foo ); static::assertEquals( - Captchavel::ANDROID, - $instance->getChallenge('token', '127.0.0.1', 'android')->version + 'bar', + $instance->getChallenge('token', '127.0.0.1', 'android', Captchavel::INPUT)->foo ); } - public function test_default_response_singleton_never_resolved() + public function test_exception_if_no_v3_secret_issued(): void { - static::assertFalse(app(ReCaptchaResponse::class)->isResolved()); - static::assertNull(app(ReCaptchaResponse::class)->version); - } - - public function test_exception_if_no_v3_secret_issued() - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('The reCAPTCHA secret for [score] doesn\'t exists'); + $this->expectException(LogicException::class); + $this->expectExceptionMessage("The reCAPTCHA secret for [score] doesn't exists or is not set."); - app(Captchavel::class)->getChallenge('token', '127.0.0.1', 'score'); + app(Captchavel::class)->getChallenge('token', '127.0.0.1', 'score', Captchavel::INPUT); } - public function test_exception_when_invalid_credentials_issued() + public function test_exception_when_invalid_credentials_issued(): void { $this->expectException(LogicException::class); - $this->expectExceptionMessage('The reCAPTCHA mode must be: checkbox, invisible, android, score'); + $this->expectExceptionMessage("The reCAPTCHA secret for [invalid] doesn't exists or is not set."); - app(Captchavel::class)->getChallenge('token', '127.0.0.1', 'invalid'); + app(Captchavel::class)->getChallenge('token', '127.0.0.1', 'invalid', Captchavel::INPUT); } - public function test_receives_v3_secret() + public function test_receives_v3_secret(): void { config(['captchavel.credentials.score.secret' => 'secret']); $mock = $this->mock(Factory::class); $mock->shouldReceive('asForm')->withNoArgs()->once()->andReturnSelf(); + $mock->shouldReceive('async')->withNoArgs()->once()->andReturnSelf(); $mock->shouldReceive('withOptions')->with(['version' => 2.0])->once()->andReturnSelf(); $mock->shouldReceive('post') ->with(Captchavel::RECAPTCHA_ENDPOINT, [ @@ -217,18 +212,19 @@ public function test_receives_v3_secret() 'remoteip' => '127.0.0.1', ]) ->once() - ->andReturn(new Response(new GuzzleResponse(200, ['Content-type' => 'application/json'], json_encode([ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ])))); + ->andReturn( + $this->fulfilledPromise([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ]) + ); + /** @var \DarkGhostHunter\Captchavel\Captchavel $instance */ $instance = app(Captchavel::class); - $score = $instance->getChallenge('token', '127.0.0.1', 'score'); + $score = $instance->getChallenge('token', '127.0.0.1', 'score', Captchavel::INPUT); - static::assertEquals('score', $score->version); - static::assertTrue($score->isResolved()); static::assertTrue($score->success); static::assertSame(0.5, $score->score); static::assertSame('bar', $score->foo); diff --git a/tests/CreatesFulfilledResponse.php b/tests/CreatesFulfilledResponse.php new file mode 100644 index 0000000..76a8c34 --- /dev/null +++ b/tests/CreatesFulfilledResponse.php @@ -0,0 +1,40 @@ + true], + string $input = Captchavel::INPUT, + string $action = null, + ): ReCaptchaResponse + { + return new ReCaptchaResponse( + $this->fulfilledPromise($properties), + $input, + $action, + ); + } + + protected function fulfilledPromise(array $properties = ['success' => true]): FulfilledPromise + { + return new FulfilledPromise( + new Response( + new GuzzleResponse( + 200, ['Content-type' => 'application/json'], json_encode($properties, JSON_THROW_ON_ERROR) + ) + ) + ); + } +} diff --git a/tests/HelperTest.php b/tests/HelperTest.php index 66ab6b6..886ee2f 100644 --- a/tests/HelperTest.php +++ b/tests/HelperTest.php @@ -10,7 +10,7 @@ class HelperTest extends TestCase { use RegistersPackage; - public function test_exception_when_no_v3_key_loaded() + public function test_exception_when_no_v3_key_loaded(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('The reCAPTCHA site key for [3] doesn\'t exist.'); @@ -18,14 +18,14 @@ public function test_exception_when_no_v3_key_loaded() captchavel(3); } - public function test_retrieves_test_keys_by_default() + public function test_retrieves_test_keys_by_default(): void { static::assertSame(Captchavel::TEST_V2_KEY, captchavel('checkbox')); static::assertSame(Captchavel::TEST_V2_KEY, captchavel('invisible')); static::assertSame(Captchavel::TEST_V2_KEY, captchavel('android')); } - public function test_retrieves_secrets() + public function test_retrieves_secrets(): void { config(['captchavel.credentials' => [ 'checkbox' => ['key' => 'key-checkbox'], diff --git a/tests/Http/Middleware/ChallengeMiddlewareTest.php b/tests/Http/Middleware/ChallengeMiddlewareTest.php index f23e34b..360da9b 100644 --- a/tests/Http/Middleware/ChallengeMiddlewareTest.php +++ b/tests/Http/Middleware/ChallengeMiddlewareTest.php @@ -3,15 +3,18 @@ namespace Tests\Http\Middleware; use DarkGhostHunter\Captchavel\Captchavel; -use DarkGhostHunter\Captchavel\Events\ReCaptchaResponseReceived; -use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; +use LogicException; use Orchestra\Testbench\TestCase; +use Tests\CreatesFulfilledResponse; use Tests\RegistersPackage; +use function trans; + class ChallengeMiddlewareTest extends TestCase { use RegistersPackage; use UsesRoutesWithMiddleware; + use CreatesFulfilledResponse; protected function setUp(): void { @@ -25,7 +28,18 @@ function () { parent::setUp(); } - public function test_exception_if_no_challenge_specified() + public function test_exception_if_declaring_v2_middleware_as_score(): void + { + $this->app['router']->post('v2/score', function () { + })->middleware('recaptcha:score'); + + $exception = $this->post('v2/score')->assertStatus(500)->exception; + + static::assertInstanceOf(LogicException::class, $exception); + static::assertSame('Use the [recaptcha.score] middleware to capture score-driven reCAPTCHA.', $exception->getMessage()); + } + + public function test_exception_if_no_challenge_specified(): void { config()->set('app.debug', false); @@ -41,17 +55,18 @@ function () { $this->postJson('test')->assertJson(['message' => 'Server Error']); } - public function test_bypass_if_not_enabled() + public function test_bypass_if_not_enabled(): void { config(['captchavel.enable' => false]); - $this->mock(Captchavel::class)->shouldNotReceive('resolve'); + $this->spy(Captchavel::class)->shouldNotReceive('getChallenge'); $this->post('v2/checkbox')->assertOk(); $this->post('v2/invisible')->assertOk(); $this->post('v2/android')->assertOk(); } - public function test_success_when_enabled_and_fake() + + public function test_success_when_enabled_and_fake(): void { config(['captchavel.enable' => true]); config(['captchavel.fake' => true]); @@ -64,7 +79,7 @@ public function test_success_when_enabled_and_fake() $this->post('v2/android/input_bar')->assertOk(); } - public function test_success_when_disabled() + public function test_success_when_disabled(): void { config(['captchavel.enable' => false]); @@ -76,239 +91,332 @@ public function test_success_when_disabled() $this->post('v2/android/input_bar')->assertOk(); } - public function test_validates_if_real() + public function test_validates_if_real(): void { $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ); + $mock->shouldReceive('isEnabled')->times(3)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(3)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + ]); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'android')->andReturn($response); + $mock->shouldReceive('getChallenge')->once() + ->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge')->once() + ->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge')->once() + ->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); } - public function test_uses_custom_input() + public function test_uses_custom_input(): void { $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ); - - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'android')->andReturn($response); - - $this->post('v2/checkbox/input_bar',['bar' => 'token'])->assertOk(); - $this->post('v2/invisible/input_bar',['bar' => 'token'])->assertOk(); - $this->post('v2/android/input_bar',['bar' => 'token'])->assertOk(); + $mock->shouldReceive('isEnabled')->times(3)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(3)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ], 'bar'); + + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'checkbox', 'bar')->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'invisible', 'bar')->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'android', 'bar')->andReturn($response); + + $this->post('v2/checkbox/input_bar', ['bar' => 'token'])->assertOk(); + $this->post('v2/invisible/input_bar', ['bar' => 'token'])->assertOk(); + $this->post('v2/android/input_bar', ['bar' => 'token'])->assertOk(); } - public function test_exception_when_token_absent() + public function test_exception_when_token_absent(): void { $mock = $this->mock(Captchavel::class); + $mock->shouldReceive('isEnabled')->times(12)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(12)->andReturnFalse(); + $mock->shouldNotReceive('getChallenge'); - $this->post('v2/checkbox')->assertRedirect('/'); + $this->post('v2/checkbox') + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.missing')) + ->assertRedirect('/'); $this->postJson('v2/checkbox')->assertJsonValidationErrors(Captchavel::INPUT); - $this->post('v2/invisible')->assertRedirect('/'); + $this->post('v2/invisible') + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.missing')) + ->assertRedirect('/'); $this->postJson('v2/invisible')->assertJsonValidationErrors(Captchavel::INPUT); - $this->post('v2/android')->assertRedirect('/'); + $this->post('v2/android') + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.missing')) + ->assertRedirect('/'); $this->postJson('v2/android')->assertJsonValidationErrors(Captchavel::INPUT); - $this->post('v2/checkbox/input_bar')->assertRedirect('/'); + $this->post('v2/checkbox/input_bar') + ->assertSessionHasErrors('bar', trans('captchavel::validation.missing')) + ->assertRedirect('/'); $this->postJson('v2/checkbox/input_bar')->assertJsonValidationErrors('bar'); - $this->post('v2/invisible/input_bar')->assertRedirect('/'); + $this->post('v2/invisible/input_bar') + ->assertSessionHasErrors('bar', trans('captchavel::validation.missing')) + ->assertRedirect('/'); $this->postJson('v2/invisible/input_bar')->assertJsonValidationErrors('bar'); - $this->post('v2/android/input_bar')->assertRedirect('/'); + $this->post('v2/android/input_bar') + ->assertSessionHasErrors('bar', trans('captchavel::validation.missing')) + ->assertRedirect('/'); $this->postJson('v2/android/input_bar')->assertJsonValidationErrors('bar'); } - public function test_exception_when_response_invalid() + public function test_exception_when_response_failed(): void { $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => false, - 'score' => 0.5, - 'foo' => 'bar', - ] - ); + $mock->shouldReceive('isEnabled')->times(6)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(6)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'success' => false, + 'foo' => 'bar', + ]); + + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.error')) + ->assertRedirect('/'); + $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/invisible', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.error')) + ->assertRedirect('/'); + $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/android', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.error')) + ->assertRedirect('/'); + $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + } + + public function test_exception_when_response_invalid(): void + { + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('isEnabled')->times(6)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(6)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'foo' => 'bar', + ]); - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'android')->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); - $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->post('v2/checkbox', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.error')) + ->assertRedirect('/'); $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); - $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->post('v2/invisible', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.error')) + ->assertRedirect('/'); $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); - $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->post('v2/android', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.error')) + ->assertRedirect('/'); $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); } - public function test_no_error_if_not_hostname_issued() + public function test_no_error_if_not_hostname_issued(): void { config(['captchavel.hostname' => null]); $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ); + $mock->shouldReceive('isEnabled')->times(3)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(3)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + ]); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'android')->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); } - public function test_no_error_if_not_hostname_same() + public function test_no_error_if_not_hostname_same(): void { config(['captchavel.hostname' => 'foo']); $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - 'hostname' => 'foo', - ] - ); + $mock->shouldReceive('isEnabled')->times(3)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(3)->andReturnFalse(); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'android')->andReturn($response); + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + 'hostname' => 'foo', + ]); + + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); } - public function test_exception_if_hostname_not_equal() + public function test_exception_if_hostname_not_equal(): void { config(['captchavel.hostname' => 'bar']); $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - 'hostname' => 'foo', - ] - ); - - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'android')->andReturn($response); - - $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertRedirect('/'); - $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('hostname'); - $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertRedirect('/'); - $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('hostname'); - $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertRedirect('/'); - $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('hostname'); + $mock->shouldReceive('isEnabled')->times(6)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(6)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + 'hostname' => 'foo', + ]); + + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) + ->assertRedirect('/'); + $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/invisible', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) + ->assertRedirect('/'); + $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/android', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) + ->assertRedirect('/'); + $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); } - public function test_no_error_if_no_apk_issued() + public function test_no_error_if_no_apk_issued(): void { config(['captchavel.apk_package_name' => null]); $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - 'apk_package_name' => 'foo', - ] - ); + $mock->shouldReceive('isEnabled')->times(3)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(3)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + 'apk_package_name' => 'foo', + ]); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'android')->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); } - public function test_no_error_if_no_apk_same() + public function test_no_error_if_no_apk_same(): void { config(['captchavel.apk_package_name' => 'foo']); $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - 'apk_package_name' => 'foo', - ] - ); + $mock->shouldReceive('isEnabled')->times(3)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(3)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + 'apk_package_name' => 'foo', + ]); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->once()->with('token', '127.0.0.1', 'android')->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->once()->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); } - public function test_exception_if_apk_not_equal() + public function test_exception_if_apk_not_equal(): void { config(['captchavel.apk_package_name' => 'bar']); $mock = $this->mock(Captchavel::class); - $response = new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - 'apk_package_name' => 'foo', - ] - ); - - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'checkbox')->andReturn($response); - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'invisible')->andReturn($response); - $mock->shouldReceive('getChallenge')->twice()->with('token', '127.0.0.1', 'android')->andReturn($response); - - $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertRedirect('/'); - $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('apk_package_name'); - $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertRedirect('/'); - $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('apk_package_name'); - $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertRedirect('/'); - $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('apk_package_name'); + $mock->shouldReceive('isEnabled')->times(6)->andReturnTrue(); + $mock->shouldReceive('shouldFake')->times(6)->andReturnFalse(); + + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + 'apk_package_name' => 'foo', + ]); + + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'checkbox', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'invisible', Captchavel::INPUT)->andReturn($response); + $mock->shouldReceive('getChallenge') + ->twice()->with('token', '127.0.0.1', 'android', Captchavel::INPUT)->andReturn($response); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) + ->assertRedirect('/'); + $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/invisible', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) + ->assertRedirect('/'); + $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/android', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) + ->assertRedirect('/'); + $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); } } diff --git a/tests/Http/Middleware/ScoreMiddlewareTest.php b/tests/Http/Middleware/ScoreMiddlewareTest.php index fa488fb..054e073 100644 --- a/tests/Http/Middleware/ScoreMiddlewareTest.php +++ b/tests/Http/Middleware/ScoreMiddlewareTest.php @@ -1,76 +1,102 @@ -afterApplicationCreated( - function () { + function (): void { $this->createsRoutes(); - config(['captchavel.fake' => false]); + $this->app['config']->set('captchavel.fake', false); } ); parent::setUp(); } - public function test_bypass_if_not_enabled() + public function test_fakes_response_if_authenticated_in_guard(): void + { + $this->app['router']->post('v3/guarded', function (ReCaptchaResponse $response) { + return $response; + })->middleware(ReCaptcha::score()->except('web')->toString()); + + $this->actingAs(User::make(), 'web'); + + $this->post('v3/guarded')->assertOk(); + $this->assertEquals(1.0, $this->app[ReCaptchaResponse::class]->score); + $this->assertInstanceOf(CaptchavelFake::class, $this->app[Captchavel::class]); + } + + public function test_fakes_response_if_not_enabled(): void { config(['captchavel.enable' => false]); - $this->mock(Captchavel::class)->shouldNotReceive('getChallenge'); + $this->post('v3/default')->assertOk(); + + $this->assertEquals(1.0, $this->app[ReCaptchaResponse::class]->score); + $this->assertInstanceOf(CaptchavelFake::class, $this->app[Captchavel::class]); + } + + public function test_fakes_response_if_enabled_and_fake(): void + { + config(['captchavel.enable' => true]); + config(['captchavel.fake' => true]); $this->post('v3/default')->assertOk(); + + $this->assertEquals(1.0, $this->app[ReCaptchaResponse::class]->score); + $this->assertInstanceOf(CaptchavelFake::class, $this->app[Captchavel::class]); } - public function test_validates_if_real() + public function test_validates_if_real(): void { $mock = $this->mock(Captchavel::class); + $mock->shouldReceive('isDisabled')->once()->andReturnFalse(); + + $mock->shouldReceive('shouldFake')->once()->andReturnFalse(); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - new ReCaptchaResponse( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ) + $this->fulfilledResponse([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ]) ); - $this->post( - 'v3/default', - [ - Captchavel::INPUT => 'token', - ] - ) + $this->post('v3/default', [Captchavel::INPUT => 'token']) ->assertOk() - ->assertExactJson( - [ - 'success' => true, - 'score' => 0.5, - 'foo' => 'bar', - ] - ); + ->assertExactJson([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ]); } - public function test_fakes_human_response_automatically() + public function test_fakes_human_response_automatically(): void { config(['captchavel.fake' => true]); @@ -80,17 +106,17 @@ public function test_fakes_human_response_automatically() ->assertOk() ->assertExactJson( [ - 'success' => true, - 'score' => 1, - 'action' => null, - 'hostname' => null, + 'success' => true, + 'score' => 1, + 'action' => null, + 'hostname' => null, 'apk_package_name' => null, - 'challenge_ts' => Carbon::now()->toAtomString(), + 'challenge_ts' => Carbon::now()->toAtomString(), ] ); } - public function test_fakes_robot_response_if_input_is_robot_present() + public function test_fakes_robot_response_if_input_is_robot_present(): void { config(['captchavel.fake' => true]); @@ -100,27 +126,25 @@ public function test_fakes_robot_response_if_input_is_robot_present() ->assertOk() ->assertExactJson( [ - 'success' => true, - 'score' => 0, - 'action' => null, - 'hostname' => null, + 'success' => true, + 'score' => 0, + 'action' => null, + 'hostname' => null, 'apk_package_name' => null, - 'challenge_ts' => Carbon::now()->toAtomString(), + 'challenge_ts' => Carbon::now()->toAtomString(), ] ); } - public function test_uses_custom_threshold() + public function test_uses_custom_threshold(): void { - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'score' => 0.7, 'foo' => 'bar'])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() - ); + $this->fulfilledResponse(['success' => true, 'score' => 0.7, 'foo' => 'bar']) + ); $this->app['router']->post('test', function (ReCaptchaResponse $response) { return [$response->isHuman(), $response->isRobot(), $response->score]; @@ -131,16 +155,14 @@ public function test_uses_custom_threshold() ->assertExactJson([true, false, 0.7]); } - public function test_uses_custom_input() + public function test_uses_custom_input(): void { - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, 'foo', null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'score' => 0.7, 'foo' => 'bar'])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'score' => 0.7, 'foo' => 'bar']) ); $this->app['router']->post('test', function (ReCaptchaResponse $response) { @@ -152,46 +174,44 @@ public function test_uses_custom_input() ->assertExactJson(['success' => true, 'score' => 0.7, 'foo' => 'bar']); } - public function test_exception_when_token_absent() + public function test_exception_when_token_absent(): void { $this->post('v3/default', ['foo' => 'bar']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.error')) ->assertRedirect('/'); $this->postJson('v3/default', ['foo' => 'bar']) ->assertJsonValidationErrors(Captchavel::INPUT); } - public function test_exception_when_response_invalid() + public function test_exception_when_response_invalid(): void { - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => false, 'score' => 0.7, 'foo' => 'bar'])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => false, 'score' => 0.7, 'foo' => 'bar']) ); $this->post('v3/default', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.error')) ->assertRedirect('/'); $this->postJson('v3/default', ['foo' => 'bar']) ->assertJsonValidationErrors(Captchavel::INPUT); } - public function test_no_error_if_not_hostname_issued() + public function test_no_error_if_not_hostname_issued(): void { config(['captchavel.hostname' => null]); - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'score' => 0.7, 'hostname' => 'foo'])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'score' => 0.7, 'hostname' => 'foo']) ); $this->post('v3/default', [Captchavel::INPUT => 'token']) @@ -201,18 +221,16 @@ public function test_no_error_if_not_hostname_issued() ->assertOk(); } - public function test_no_error_if_hostname_same() + public function test_no_error_if_hostname_same(): void { config(['captchavel.hostname' => 'bar']); - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'score' => 0.7, 'hostname' => 'bar'])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'score' => 0.7, 'hostname' => 'bar']) ); $this->post('v3/default', [Captchavel::INPUT => 'token']) @@ -222,39 +240,36 @@ public function test_no_error_if_hostname_same() ->assertOk(); } - public function test_exception_if_hostname_not_equal() + public function test_exception_if_hostname_not_equal(): void { config(['captchavel.hostname' => 'bar']); - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'score' => 0.7, 'hostname' => 'foo'])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'score' => 0.7, 'hostname' => 'foo']) ); $this->post('v3/default', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) ->assertRedirect('/'); $this->postJson('v3/default', [Captchavel::INPUT => 'token']) - ->assertJsonValidationErrors('hostname'); + ->assertJsonValidationErrors(Captchavel::INPUT); } - public function test_no_error_if_no_apk_issued() + public function test_no_error_if_no_apk_issued(): void { config(['captchavel.apk_package_name' => null]); - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'score' => 0.7, 'apk_package_name' => 'foo'])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'score' => 0.7, 'apk_package_name' => 'foo']) ); $this->post('v3/default', [Captchavel::INPUT => 'token']) @@ -264,18 +279,16 @@ public function test_no_error_if_no_apk_issued() ->assertOk(); } - public function test_no_error_if_apk_same() + public function test_no_error_if_apk_same(): void { config(['captchavel.apk_package_name' => 'foo']); - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'score' => 0.7, 'apk_package_name' => 'foo'])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'score' => 0.7, 'apk_package_name' => 'foo']) ); $this->post('v3/default', [Captchavel::INPUT => 'token']) @@ -285,37 +298,34 @@ public function test_no_error_if_apk_same() ->assertOk(); } - public function test_exception_if_apk_not_equal() + public function test_exception_if_apk_not_equal(): void { config(['captchavel.apk_package_name' => 'bar']); - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'score' => 0.7, 'apk_package_name' => null])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'score' => 0.7, 'apk_package_name' => null]) ); $this->post('v3/default', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) ->assertRedirect('/'); $this->postJson('v3/default', [Captchavel::INPUT => 'token']) - ->assertJsonValidationErrors('apk_package_name'); + ->assertJsonValidationErrors(Captchavel::INPUT); } - public function test_no_error_if_no_action() + public function test_no_error_if_no_action(): void { - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, null) ->andReturn( - (new ReCaptchaResponse(['success' => true, 'action' => 'foo', 'apk_package_name' => null])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'action' => 'foo', 'apk_package_name' => null]) ); $this->app['router']->post('test', function (ReCaptchaResponse $response) { @@ -325,16 +335,14 @@ public function test_no_error_if_no_action() $this->post('test', [Captchavel::INPUT => 'token'])->assertOk(); } - public function test_no_error_if_action_same() + public function test_no_error_if_action_same(): void { - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, 'foo') ->andReturn( - (new ReCaptchaResponse(['success' => true, 'action' => 'foo', 'apk_package_name' => null])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse(['success' => true, 'action' => 'foo', 'apk_package_name' => null]) ); $this->app['router']->post('test', function (ReCaptchaResponse $response) { @@ -344,16 +352,18 @@ public function test_no_error_if_action_same() $this->post('test', [Captchavel::INPUT => 'token'])->assertOk(); } - public function test_exception_if_action_not_equal() + public function test_exception_if_action_not_equal(): void { - $mock = $this->mock(Captchavel::class); + $mock = $this->spy(Captchavel::class); $mock->shouldReceive('getChallenge') - ->with('token', '127.0.0.1', 'score') + ->with('token', '127.0.0.1', Captchavel::SCORE, Captchavel::INPUT, 'bar') ->andReturn( - (new ReCaptchaResponse(['success' => true, 'action' => 'foo', 'apk_package_name' => null])) - ->setVersion(Captchavel::SCORE) - ->setAsResolved() + $this->fulfilledResponse( + ['success' => true, 'action' => 'foo', 'apk_package_name' => null], + Captchavel::INPUT, + 'bar' + ) ); $this->app['router']->post( @@ -363,40 +373,38 @@ function (ReCaptchaResponse $response) { } )->middleware('recaptcha.score:null,bar'); - $this->post('test', [Captchavel::INPUT => 'token'])->assertRedirect('/'); - $this->postJson('test', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('action'); + $this->post('test', [Captchavel::INPUT => 'token']) + ->assertSessionHasErrors(Captchavel::INPUT, trans('captchavel::validation.match')) + ->assertRedirect('/'); + $this->postJson('test', [Captchavel::INPUT => 'token']) + ->assertJsonValidationErrors(Captchavel::INPUT); } - public function test_checks_for_human_score() + public function test_checks_for_human_score(): void { config(['captchavel.credentials.score.secret' => 'secret']); config(['captchavel.fake' => false]); $mock = $this->mock(Factory::class); + $mock->shouldReceive('async')->withNoArgs()->times(4)->andReturnSelf(); $mock->shouldReceive('asForm')->withNoArgs()->times(4)->andReturnSelf(); $mock->shouldReceive('withOptions')->with(['version' => 2.0])->times(4)->andReturnSelf(); $mock->shouldReceive('post') ->with( Captchavel::RECAPTCHA_ENDPOINT, [ - 'secret' => 'secret', + 'secret' => 'secret', 'response' => 'token', 'remoteip' => '127.0.0.1', ] ) ->times(4) ->andReturn( - new Response( - new GuzzleResponse( - 200, ['Content-type' => 'application/json'], json_encode( - [ - 'success' => true, - 'score' => 0.5, - ] - ) - ) - ) + $this->fulfilledPromise([ + 'success' => true, + 'score' => 0.5, + ]) ); $this->app['router']->post( diff --git a/tests/Http/Middleware/UsesRoutesWithMiddleware.php b/tests/Http/Middleware/UsesRoutesWithMiddleware.php index 630385c..8d521d9 100644 --- a/tests/Http/Middleware/UsesRoutesWithMiddleware.php +++ b/tests/Http/Middleware/UsesRoutesWithMiddleware.php @@ -30,28 +30,40 @@ protected function createsRoutes() return $response; })->middleware('recaptcha.score:null,null,bar'); - $this->app['router']->post('v2/checkbox', function (ReCaptchaResponse $response) { - return $response; + $this->app['router']->post('v2/checkbox', function () { + if (app()->has(ReCaptchaResponse::class)) { + return app(ReCaptchaResponse::class); + } })->middleware('recaptcha:checkbox'); - $this->app['router']->post('v2/checkbox/input_bar', function (ReCaptchaResponse $response) { - return $response; + $this->app['router']->post('v2/checkbox/input_bar', function () { + if (app()->has(ReCaptchaResponse::class)) { + return app(ReCaptchaResponse::class); + } })->middleware('recaptcha:checkbox,bar'); - $this->app['router']->post('v2/invisible', function (ReCaptchaResponse $response) { - return $response; + $this->app['router']->post('v2/invisible', function () { + if (app()->has(ReCaptchaResponse::class)) { + return app(ReCaptchaResponse::class); + } })->middleware('recaptcha:invisible'); - $this->app['router']->post('v2/invisible/input_bar', function (ReCaptchaResponse $response) { - return $response; + $this->app['router']->post('v2/invisible/input_bar', function () { + if (app()->has(ReCaptchaResponse::class)) { + return app(ReCaptchaResponse::class); + } })->middleware('recaptcha:invisible,bar'); - $this->app['router']->post('v2/android', function (ReCaptchaResponse $response) { - return $response; + $this->app['router']->post('v2/android', function () { + if (app()->has(ReCaptchaResponse::class)) { + return app(ReCaptchaResponse::class); + } })->middleware('recaptcha:android'); - $this->app['router']->post('v2/android/input_bar', function (ReCaptchaResponse $response) { - return $response; + $this->app['router']->post('v2/android/input_bar', function () { + if (app()->has(ReCaptchaResponse::class)) { + return app(ReCaptchaResponse::class); + } })->middleware('recaptcha:android,bar'); } } diff --git a/tests/Http/ReCaptchaResponseTest.php b/tests/Http/ReCaptchaResponseTest.php index d6ef251..5eaa04a 100644 --- a/tests/Http/ReCaptchaResponseTest.php +++ b/tests/Http/ReCaptchaResponseTest.php @@ -4,59 +4,165 @@ use DarkGhostHunter\Captchavel\Captchavel; use DarkGhostHunter\Captchavel\Http\ReCaptchaResponse; +use GuzzleHttp\Promise\Promise; +use GuzzleHttp\Psr7\Response as GuzzleResponse; +use Illuminate\Http\Client\Response; +use Illuminate\Validation\ValidationException; use Orchestra\Testbench\TestCase; -use RuntimeException; +use Tests\CreatesFulfilledResponse; use Tests\RegistersPackage; +use function json_encode; +use function now; +use function trans; + +use const JSON_THROW_ON_ERROR; + class ReCaptchaResponseTest extends TestCase { use RegistersPackage; + use CreatesFulfilledResponse; - public function test_exception_when_checking_non_score_response() + public function test_can_be_json(): void { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('This is not a reCAPTCHA v3 response'); + $response = $this->fulfilledResponse(['success' => true, 'foo' => 'bar']); - (new ReCaptchaResponse([ - 'success' => true, - ]))->setAsResolved()->isHuman(); + static::assertEquals('{"success":true,"foo":"bar"}', $response->toJson()); } - public function test_exception_when_score_response_has_no_score() + public function test_can_be_array(): void { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('This is reCAPTCHA v3 response has no score'); + $response = $this->fulfilledResponse($array = ['success' => true, 'foo' => 'bar']); - (new ReCaptchaResponse([ - 'success' => true, - ]))->setVersion(Captchavel::SCORE)->setAsResolved()->isHuman(); + static::assertEquals($array, $response->toArray()); } - public function test_is_checkbox_response() + public function test_access_attributes_as_properties() { - static::assertTrue( - (new ReCaptchaResponse())->setVersion(Captchavel::CHECKBOX)->isCheckbox() - ); + $response = $this->fulfilledResponse(['success' => true, 'foo' => 'bar']); + + static::assertTrue(isset($response->foo)); + static::assertEquals('bar', $response->foo); + + unset($response->foo); + + static::assertFalse(isset($response->foo)); + static::assertNull($response->foo); + + $response->foo = 'bar'; + + static::assertEquals('bar', $response->foo); } - public function test_is_invisible_response() + public function test_checks_resolve(): void { - static::assertTrue( - (new ReCaptchaResponse())->setVersion(Captchavel::INVISIBLE)->isInvisible() + $response = new ReCaptchaResponse( + $promise = new Promise(), + Captchavel::INPUT, ); + + static::assertFalse($response->isResolved()); + static::assertTrue($response->isPending()); + + $promise->resolve( + new Response( + new GuzzleResponse(200, ['Content-type' => 'application/json'], json_encode([ + 'success' => true, + 'foo' => 'bar', + ], JSON_THROW_ON_ERROR)) + ) + ); + + $response->wait(); + + static::assertTrue($response->isResolved()); + static::assertFalse($response->isPending()); } - public function test_is_android_response() + public function test_always_returns_human_if_not_score_response(): void { - static::assertTrue( - (new ReCaptchaResponse())->setVersion(Captchavel::ANDROID)->isAndroid() - ); + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + ]); + + static::assertTrue($response->isHuman()); + static::assertFalse($response->isRobot()); } - public function test_is_score_response() + public function test_returns_carbon_of_challenge_ts(): void { - static::assertTrue( - (new ReCaptchaResponse())->setVersion(Captchavel::SCORE)->isScore() + $response = $this->fulfilledResponse([ + 'success' => true, + 'foo' => 'bar', + 'challenge_ts' => ($now = now())->toIso8601ZuluString(), + ]); + + static::assertEquals($now->startOfSecond(), $response->carbon()); + } + + public function test_attributes_always_empty_until_resolved(): void + { + $response = new ReCaptchaResponse( + $promise = new Promise(), + Captchavel::INPUT, + ); + + static::assertEmpty($response->getAttributes()); + + $promise->resolve( + new Response( + new GuzzleResponse(200, ['Content-type' => 'application/json'], json_encode([ + 'success' => true, + 'foo' => 'bar', + ], JSON_THROW_ON_ERROR)) + ) ); + + $response->wait(); + + static::assertNotEmpty($response->getAttributes()); + } + + public function test_validation_fails_if_no_success(): void + { + $this->expectException(ValidationException::class); + + $response = $this->fulfilledResponse([ + 'success' => false, + 'foo' => 'bar', + 'errors' => ['quz', 'cougar'], + ]); + + try { + $response->wait(); + } catch (ValidationException $exception) { + static::assertArrayHasKey(Captchavel::INPUT, $exception->errors()); + static::assertEquals($exception->errors()[Captchavel::INPUT], [ + trans('captchavel::validation.error', ['errors' => 'quz, cougar']), + ]); + + throw $exception; + } + } + + public function test_validation_fails_if_success_absent(): void + { + $this->expectException(ValidationException::class); + + $response = $this->fulfilledResponse([ + 'foo' => 'bar', + ]); + + try { + $response->wait(); + } catch (ValidationException $exception) { + static::assertArrayHasKey(Captchavel::INPUT, $exception->errors()); + static::assertEquals($exception->errors()[Captchavel::INPUT], [ + trans('captchavel::validation.error', ['errors' => '']), + ]); + + throw $exception; + } } } diff --git a/tests/ReCaptchaMiddlewareHelperTest.php b/tests/ReCaptchaMiddlewareHelperTest.php new file mode 100644 index 0000000..687480a --- /dev/null +++ b/tests/ReCaptchaMiddlewareHelperTest.php @@ -0,0 +1,112 @@ +input('foo')); + static::assertEquals('recaptcha:checkbox,g-recaptcha-response,bar', (string) ReCaptcha::checkbox()->except('bar')); + } + + public function test_creates_full_recaptcha_v2_invisible_string(): void + { + static::assertEquals('recaptcha:invisible,g-recaptcha-response', (string) ReCaptcha::invisible()); + static::assertEquals('recaptcha:invisible,foo', (string) ReCaptcha::invisible()->input('foo')); + static::assertEquals('recaptcha:invisible,g-recaptcha-response,bar', (string) ReCaptcha::invisible()->except('bar')); + } + + public function test_creates_full_recaptcha_v2_android_string(): void + { + static::assertEquals('recaptcha:android,g-recaptcha-response', (string) ReCaptcha::android()); + static::assertEquals('recaptcha:android,foo', (string) ReCaptcha::android()->input('foo')); + static::assertEquals('recaptcha:android,g-recaptcha-response,bar', (string) ReCaptcha::android()->except('bar')); + } + + public function test_exception_if_using_v3_methods_on_v2_checkbox(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('You cannot set [threshold] for a [checkbox] middleware.'); + ReCaptcha::checkbox()->threshold(1); + } + + public function test_exception_if_using_v3_methods_on_v2_invisible(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('You cannot set [action] for a [invisible] middleware.'); + ReCaptcha::invisible()->action('route'); + } + + public function test_exception_if_using_v3_methods_on_v2_android(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('You cannot set [threshold] for a [android] middleware.'); + ReCaptcha::android()->threshold(1); + } + + public function test_creates_full_recaptcha_v3_score_string() + { + static::assertSame( + 'recaptcha.score:0.5,null,g-recaptcha-response', + (string) ReCaptcha::score() + ); + + static::assertSame( + 'recaptcha.score:0.3,bar,foo', + (string) ReCaptcha::score()->input('foo')->threshold(0.3)->action('bar') + ); + + static::assertSame( + 'recaptcha.score:0.3,bar,foo,quz,cougar', + (string) ReCaptcha::score()->except('quz', 'cougar')->threshold(0.3)->action('bar')->input('foo') + ); + } + + public function test_uses_threshold_from_config() + { + static::assertSame( + 'recaptcha.score:0.5,null,g-recaptcha-response', + (string) ReCaptcha::score() + ); + + config(['captchavel.threshold' => 0.1]); + + static::assertSame( + 'recaptcha.score:0.1,null,g-recaptcha-response', + (string) ReCaptcha::score() + ); + } + + public function test_normalizes_threshold(): void + { + static::assertSame( + 'recaptcha.score:1.0,null,g-recaptcha-response', + (string) ReCaptcha::score(1.7) + ); + + static::assertSame( + 'recaptcha.score:0.0,null,g-recaptcha-response', + (string) ReCaptcha::score(-9) + ); + + static::assertSame( + 'recaptcha.score:1.0,null,g-recaptcha-response', + (string) ReCaptcha::score()->threshold(1.7) + ); + + static::assertSame( + 'recaptcha.score:0.0,null,g-recaptcha-response', + (string) ReCaptcha::score()->threshold(-9) + ); + } +} diff --git a/tests/RegistersPackage.php b/tests/RegistersPackage.php index c69a80c..74af526 100644 --- a/tests/RegistersPackage.php +++ b/tests/RegistersPackage.php @@ -15,5 +15,4 @@ protected function getPackageProviders($app) { return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; } - } diff --git a/tests/RequestMacroTest.php b/tests/RequestMacroTest.php new file mode 100644 index 0000000..ef1988e --- /dev/null +++ b/tests/RequestMacroTest.php @@ -0,0 +1,33 @@ +instance(ReCaptchaResponse::class, + $this->fulfilledResponse(['success' => true, 'score' => 0.5])->setThreshold(0.5) + ); + + static::assertTrue(Request::isHuman()); + static::assertFalse(Request::isRobot()); + } + + public function test_checks_if_robot(): void + { + $this->instance(ReCaptchaResponse::class, + $this->fulfilledResponse(['success' => true, 'score' => 0.2])->setThreshold(0.5) + ); + + static::assertFalse(Request::isHuman()); + static::assertTrue(Request::isRobot()); + } +} From 7c8bc093adacae1e1544212a1803ed8538639089 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 31 Oct 2021 17:48:13 -0300 Subject: [PATCH 3/4] Fixed test runs. --- .github/workflows/php.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2044172..a9a74ca 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -11,14 +11,12 @@ jobs: strategy: fail-fast: true matrix: - php: [7.4, 8.0] - laravel: [7.*, 8.*] + php: [8.0, 8.1] + laravel: [8.*, ^8.67] dependency-version: [prefer-lowest, prefer-stable] include: - - laravel: 7.* - testbench: ^5.18 - laravel: 8.* - testbench: ^6.14 + testbench: ^6.22 name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }} From f74a5d77ef38143420f33e1889f9f7aa6552c851 Mon Sep 17 00:00:00 2001 From: DarkGhosthunter Date: Sun, 31 Oct 2021 17:58:41 -0300 Subject: [PATCH 4/4] Fixed test runs #2 --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index a9a74ca..da40ac7 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -12,7 +12,7 @@ jobs: fail-fast: true matrix: php: [8.0, 8.1] - laravel: [8.*, ^8.67] + laravel: [8.*] dependency-version: [prefer-lowest, prefer-stable] include: - laravel: 8.*