# M-2 Yandex SmartCaptcha Driver Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Заменить безусловный `NullCaptchaVerifier` реальным Yandex SmartCaptcha драйвером, выбираемым по `CAPTCHA_DRIVER`.

**Architecture:** Новый `YandexSmartCaptchaVerifier implements CaptchaVerifier` шлёт серверную валидацию токена в Yandex; `AppServiceProvider` биндит драйвер через `match(config('services.captcha.driver'))`. Fail-open при недоступности Yandex. Контроллер/`RegistrationService` не трогаются (развязаны интерфейсом).

**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / `Http` фасад. Spec: [2026-06-21-m2-yandex-smartcaptcha-design.md](../specs/2026-06-21-m2-yandex-smartcaptcha-design.md).

---

### Task 1: YandexSmartCaptchaVerifier драйвер + биндинг + конфиг (TDD)

**Files:**
- Create: `app/app/Services/Captcha/YandexSmartCaptchaVerifier.php`
- Modify: `app/app/Providers/AppServiceProvider.php` (биндинг CaptchaVerifier, ~строки 54-57)
- Modify: `app/config/services.php` (блок `captcha`)
- Modify: `app/.env.example`
- Test: `app/tests/Feature/Captcha/YandexSmartCaptchaVerifierTest.php` (create)

Контекст: интерфейс `CaptchaVerifier::verify(?string $token, ?string $ip = null): bool` ([CaptchaVerifier.php](../../../app/app/Services/Captcha/CaptchaVerifier.php)). Зовётся в `RegistrationService::register` (false → отказ). Конфиг `services.captcha` имеет `driver`, `fake_passes`, `yandex_server_key`.

- [ ] **Step 1: Написать падающий тест**

`app/tests/Feature/Captcha/YandexSmartCaptchaVerifierTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Captcha\CaptchaVerifier;
use App\Services\Captcha\NullCaptchaVerifier;
use App\Services\Captcha\YandexSmartCaptchaVerifier;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    config([
        'services.captcha.yandex_server_key' => 'srv-key-test',
        'services.captcha.yandex_validate_url' => 'https://smartcaptcha.cloud.yandex.ru/validate',
    ]);
});

test('пустой токен → false без обращения к Yandex', function () {
    Http::fake();
    expect((new YandexSmartCaptchaVerifier)->verify(null))->toBeFalse();
    expect((new YandexSmartCaptchaVerifier)->verify(''))->toBeFalse();
    Http::assertNothingSent();
});

test('Yandex status=ok → true', function () {
    Http::fake(['smartcaptcha.yandexcloud.net/*' => Http::response(['status' => 'ok'], 200)]);
    expect((new YandexSmartCaptchaVerifier)->verify('tok', '203.0.113.7'))->toBeTrue();
});

test('Yandex status=failed → false', function () {
    Http::fake(['smartcaptcha.yandexcloud.net/*' => Http::response(['status' => 'failed'], 200)]);
    expect((new YandexSmartCaptchaVerifier)->verify('tok'))->toBeFalse();
});

test('Yandex 500 → fail-open true', function () {
    Http::fake(['smartcaptcha.yandexcloud.net/*' => Http::response('err', 500)]);
    expect((new YandexSmartCaptchaVerifier)->verify('tok'))->toBeTrue();
});

test('Yandex connection error → fail-open true', function () {
    Http::fake(function () {
        throw new \Illuminate\Http\Client\ConnectionException('timeout');
    });
    expect((new YandexSmartCaptchaVerifier)->verify('tok'))->toBeTrue();
});

test('запрос несёт secret+token+ip', function () {
    Http::fake(['smartcaptcha.yandexcloud.net/*' => Http::response(['status' => 'ok'], 200)]);
    (new YandexSmartCaptchaVerifier)->verify('tok-abc', '198.51.100.9');
    Http::assertSent(function (Request $r) {
        return $r['secret'] === 'srv-key-test'
            && $r['token'] === 'tok-abc'
            && $r['ip'] === '198.51.100.9';
    });
});

test('биндинг по driver: yandex → Yandex-класс, null → Null-класс', function () {
    config(['services.captcha.driver' => 'yandex']);
    expect(app(CaptchaVerifier::class))->toBeInstanceOf(YandexSmartCaptchaVerifier::class);

    config(['services.captcha.driver' => 'null']);
    expect(app(CaptchaVerifier::class))->toBeInstanceOf(NullCaptchaVerifier::class);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test tests/Feature/Captcha/YandexSmartCaptchaVerifierTest.php`
Expected: FAIL — класс `YandexSmartCaptchaVerifier` не существует (Error/biнdинг возвращает Null).

- [ ] **Step 3: Создать `app/app/Services/Captcha/YandexSmartCaptchaVerifier.php`**

```php
<?php

declare(strict_types=1);

namespace App\Services\Captcha;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Серверная валидация Yandex SmartCaptcha (M-2). Пустой токен → отказ без
 * запроса. status=ok → пройдено; status≠ok → отказ. При недоступности Yandex
 * (таймаут/не-200/сеть/битый JSON) — fail-open (true): UX выше антифрода,
 * второй рубеж — throttle регистрации. Site-key (фронт-виджет) — отдельно.
 */
final class YandexSmartCaptchaVerifier implements CaptchaVerifier
{
    public function verify(?string $token, ?string $ip = null): bool
    {
        if ($token === null || $token === '') {
            return false;
        }

        $payload = [
            'secret' => (string) config('services.captcha.yandex_server_key'),
            'token' => $token,
        ];
        if ($ip !== null) {
            $payload['ip'] = $ip;
        }

        try {
            $response = Http::timeout(2)
                ->asForm()
                ->post((string) config('services.captcha.yandex_validate_url'), $payload);

            if (! $response->successful()) {
                Log::warning('captcha.yandex_unreachable', ['http_status' => $response->status()]);

                return true; // fail-open
            }

            return $response->json('status') === 'ok';
        } catch (Throwable $e) {
            Log::warning('captcha.yandex_unreachable', ['error' => $e->getMessage()]);

            return true; // fail-open
        }
    }
}
```

- [ ] **Step 4: Добавить `yandex_validate_url` в `app/config/services.php`**

В массив `'captcha' => [ ... ]` добавить строку после `yandex_server_key`:

```php
        'yandex_validate_url' => env('YANDEX_SMARTCAPTCHA_VALIDATE_URL', 'https://smartcaptcha.cloud.yandex.ru/validate'),
```

- [ ] **Step 5: Заменить биндинг в `app/app/Providers/AppServiceProvider.php`**

Найти (строки ~52-57):
```php
        // Шов капчи самозаписи (G1/SP1). SP1 — Null-драйвер; реальный
        // Yandex SmartCaptcha встанет сюда позже через match по драйверу.
        $this->app->bind(
            CaptchaVerifier::class,
            NullCaptchaVerifier::class,
        );
```
Заменить на:
```php
        // Шов капчи самозаписи (G1/SP1 + M-2). Драйвер по CAPTCHA_DRIVER:
        // 'yandex' → реальный Yandex SmartCaptcha; иначе (включая 'null') → Null
        // (dev/test). Контроллер/RegistrationService зовут только интерфейс.
        $this->app->bind(
            CaptchaVerifier::class,
            fn () => match (config('services.captcha.driver')) {
                'yandex' => new YandexSmartCaptchaVerifier,
                default => new NullCaptchaVerifier,
            },
        );
```
Добавить `use App\Services\Captcha\YandexSmartCaptchaVerifier;` в импорты (рядом с существующим `use App\Services\Captcha\CaptchaVerifier;` / `NullCaptchaVerifier`).

- [ ] **Step 6: Запустить тест — убедиться, что проходит**

Run: `cd app && php artisan test tests/Feature/Captcha/YandexSmartCaptchaVerifierTest.php`
Expected: PASS (7/7).

- [ ] **Step 7: Регрессия captcha/registration**

Run: `cd app && php artisan test --filter="Captcha|Registration"`
Expected: PASS — драйвер по умолчанию Null, существующие тесты не ломаются.

- [ ] **Step 8: `app/.env.example`**

В блок капчи (если нет — рядом с прочими) добавить:
```
# Капча самозаписи (M-2). driver=null (dev) | yandex (prod). Для yandex нужен server-key.
CAPTCHA_DRIVER=null
YANDEX_SMARTCAPTCHA_SERVER_KEY=
```

- [ ] **Step 9: Pint + Larastan**

Run: `cd app && vendor/bin/pint app/Services/Captcha/YandexSmartCaptchaVerifier.php app/Providers/AppServiceProvider.php config/services.php tests/Feature/Captcha/YandexSmartCaptchaVerifierTest.php && composer stan`
Expected: pint passed; phpstan 0. При новых baseline-ошибках Pest dynamic-methods в тест-файле — добавить записи в `app/phpstan-baseline.neon` (паттерны для `tests/Feature/Captcha/YandexSmartCaptchaVerifierTest.php`).

- [ ] **Step 10: Commit (с разрешения владельца)**

Расстейдж observer: `git restore --staged docs/observer/STATUS.md docs/observer/.pii-counters.json`

```bash
git add app/app/Services/Captcha/YandexSmartCaptchaVerifier.php \
  app/app/Providers/AppServiceProvider.php app/config/services.php app/.env.example \
  app/tests/Feature/Captcha/YandexSmartCaptchaVerifierTest.php \
  docs/superpowers/specs/2026-06-21-m2-yandex-smartcaptcha-design.md \
  docs/superpowers/plans/2026-06-21-m2-yandex-smartcaptcha.md
# + app/phpstan-baseline.neon если правился
git commit -m "feat(security): реальный Yandex SmartCaptcha драйвер самозаписи по CAPTCHA_DRIVER (M-2)"
```

---

## Self-Review

**Spec coverage:** YandexSmartCaptchaVerifier (пустой→false / ok→true / failed→false / fail-open) ✓; match-биндинг ✓; config yandex_validate_url ✓; .env.example ✓; тесты 7 кейсов ✓; контроллер не тронут ✓; без схемы ✓.

**Placeholder scan:** весь код приведён, команды с ожидаемым выводом. Нет TBD.

**Type consistency:** `verify(?string $token, ?string $ip = null): bool` — совпадает с интерфейсом. `config('services.captcha.driver'|'.yandex_server_key'|'.yandex_validate_url')` — единые ключи в драйвере, конфиге, тесте, биндинге. Env-ключи `CAPTCHA_DRIVER`/`YANDEX_SMARTCAPTCHA_SERVER_KEY`/`YANDEX_SMARTCAPTCHA_VALIDATE_URL` — согласованы.
