Студия разработки сайтов и приложений

Netspark.ru

Заметки и разработки

OctoberCMS

reCAPTCHA и сервис-контейнер

Есть такая известная парадигма в ООП — сервис-контейнеры. Суть её в том, чтобы получать реализацию того или иного сервиса, который тебе зачем-то нужен, из некоего контейнера. То есть не задумываясь о деталях, о том, что это за класс и т.д. Обращаешься к контейнеру — а контейнер сам определит, что надо, и вернет нужный сервис. Очевидно, если заранее не знаешь, о чем речь, из такого пояснения ни фига толком не ясно. Обычно эти пояснения иллюстрируются примерами. В доках Ларавела, скажем, используется пример EventPusher-а и его реализации через Redis.

Слабое место подобных примеров, на мой взгляд, в том, что для начинающего разработчика, не очень знакомого с концепцией контейнера и dependency injection, идея замены одной реализации сервиса на другую не очень близка. В голову сразу приходит, что у меня-то сейчас один класс используется — вот этот. Когда надо будет другой, тогда и контейнер сделаю, а пока буду явно обращаться к данному классу. И это, как мне кажется, вполне нормальный ход мыслей.

Поэтому попробую привести чуть более живой (на мой взгляд, опять же) пример, связанный с тестами, показывающий, в каком случае нам понадобится замена реализации сервиса прямо сейчас, а не когда-нибудь потом.

Допустим, вы в своих проектах практикуете TDD. И у вас, помимо всех остальных тестов, проверяющих всё на свете, есть тест создания какой-нибудь модели через API Post, или просто через форму. И вот внезапно вам понадобилось подцепить на эту форму проверку reCAPTCHA против спаммеров. Реализовать саму логику работы во фронте (на Vue.js) и проверку полученного токена в бэке — нетрудно (вот пример).

А вот тест создания модели (и все связанные с этим роутом/формой тесты) — немедленно поломаются. Потому что где вы возьмете валидный ключ капчи для проверки, если он в тестовом режиме не прилетает из фронта?

Можно было бы попытаться организовать прохождение капчи для бэк-энд тестов (хотя я не совсем понял, как это именно в случае reCAPTCHA сделать). Но:

  • от этого нет пользы помимо тестирования;
  • у нас нет задачи тестировать гугл-капчу в каждом тесте бэкенда, который проверяет работу методов post/put у каждой защищенной капчей формы;
  • для тестирования внешнего сервиса достаточно одного теста интеграции — что мы верно всё подключили — а остальное есть забота разработчика внешнего сервиса;
  • если каждый тест формы будет проверять ключ капчи через сервис гугла, тесты внезапно станут значительно дольше работать.

В общем, представляется что не нужно проходить гугл-капчу на бэк-энде в тестовых целях. Вот тут-то и возникает необходимость в подмене живой капчи — фейковой, чтобы её не проходить. И это как раз неплохо иллюстрирует концепцию сервис-контейнеров и внедрения зависимостей.

Пусть у нас для проверки рекапчи есть вот такое правило валидации:

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class Recaptcha implements Rule
{
    public function passes($attribute, $value)
    {
        $recaptcha = new RecaptchaCheck();
        // в $value полученный от фронт-энда токен
        return $recaptcha->check($value);
    }

    public function message()
    {
        return 'The reCaptcha token is invalid.';
    }
}

Вызов метода RecaptchaCheck::check() здесь проверяет полученный токен капчи и говорит, правильный он, или нет. Вот этот вызов мы и хотим подменить фейковым, чтобы не долбиться в гуглосервис при каждом запуске тестов, контроллеры которых используют наше новое правило.

Для подмены сначала создадим интерфейс:

namespace App\Rules;

interface RecaptchaCheck
{

    /**
     *
     * Returns true if $token (and captcha) is valid
     *
     * @param string $token
     *
     * @return bool
     */
    public function check($token);

}

Теперь сделаем, чтобы настоящий класс проверки капчи реализовывал этот интерфейс:

namespace App\Rules;

use GuzzleHttp\Client;

class LiveRecaptchaCheck implements RecaptchaCheck
{

    /**
     *
     * Returns true if $token (and captcha) is valid
     *
     * @param string $token
     *
     * @return bool
     */
    public function check($token)
    {
        // с деталями этой реализации можете ознакомиться в статье, приведенной выше
        $client = new Client();
        $response = $client->post('https://www.google.com/recaptcha/api/siteverify', [
            'form_params' => [
                'secret' => config('services.recaptcha.secret'),
                'response' => $token,
            ],
        ]);

        $data = json_decode($response->getBody(), true);
        return $data['success'];
    }

}

Теперь создадим фейковый класс для подмены:

namespace App\Rules;

class FakeRecaptchaCheck implements RecaptchaCheck
{

    /**
     *
     * Returns true if $token (and captcha) is valid
     *
     * @param string $token
     *
     * @return bool
     */
    public function check($token)
    {
        return ($token == 'Valid reCaptcha token');
    }

}

Как мы видим, метод check() фейкового класса будет возвращать TRUE для строки 'Valid reCaptcha token' и FALSE для любых других строк.

Теперь сделаем так, чтобы наше правило валидации получало сервис проверки из контейнера:

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class Recaptcha implements Rule
{

    /** @var RecaptchaCheck */
    protected $recaptcha;

    public function __construct()
    {
        // getting reCaptcha check service
        $this->recaptcha = resolve(RecaptchaCheck::class);
    }

    public function passes($attribute, $value)
    {
        return $this->recaptcha->check($value);
    }

    public function message()
    {
        return 'The reCaptcha token is invalid.';
    }
}

Обратите внимание, что для получения сервиса явно используется хелпер resolve(). Он нужен, потому что при использовании в валидаторе (как будет показано ниже) мы добавляем правило вручную через new. Соответственно автоматический резолвер зависимостей в конструкторе здесь не сработает и ничего не подставит. Придётся вручную вставлять объект нужного класса, а этого мы как раз не хотим.

Чтобы контейнер мог зарезолвить наш интерфейс в объект нужного класса, не забудем добавить запись в сервис-провайдер:

class AppServiceProvider extends ServiceProvider
{

    // ...

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(RecaptchaCheck::class, LiveRecaptchaCheck::class);
    }
}

А использовать созданное правило в валидаторе можно так:

$request->validate([
    // ...
    'recaptcha_token' => ['required', new Recaptcha],
]);

где recaptcha_token — токен, переданный в запросе из фронт-энда (полученный, очевидно, от API рекапчи).

Вот так мы можем подменить реальный класс проверки тестовым:

protected function setUp() {
    parent::setUp();

    $this->app->bind(RecaptchaCheck::class, FakeRecaptchaCheck::class);
}

Теперь все тесты, реализующие (или унаследовавшие) такой метод setUp() будут вместо живой проверки капчи использовать фейковую.

Например, вот так фейковая проверка в тесте не пройдет валидацию:

/**
 * @test
 */
public function invalid_recaptcha_token_results_in_validation_error() {
    $response = $this->json('POST', '/api/my-form', [
        'recaptcha_token' => 'Invalid reCaptcha token',
    ]);

    $response->assertStatus(422); // validation error
    $this->assertArrayHasKey('recaptcha_token', $response->decodeResponseJson()['errors']);
}

А вот такая — пройдет:

/**
 * @test
 */
public function valid_recaptcha_token_passes_validation() {
    $response = $this->json('POST', '/api/my-form', [
        // other required fields...
        'recaptcha_token' => 'Valid reCaptcha token',
    ]);

    $response->assertStatus(200);
}

Комментарии