Files
portal/app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php
T

262 lines
15 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Providers\AutopodborServiceProvider;
use App\Services\Autopodbor\Agent\Fetch\DverRendera;
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
// Р126. Дверь к рендер-виртуалке: тайный ключ SELF_RENDER_KEY едет ЗАГОЛОВКОМ X-Render-Key в
// каждом запросе. Опечатка в адресе увела бы его чужому молча и навсегда; `http://` увёз бы его
// открытым текстом. Устройство повторяет проверенную дверь к голосовому роботу (Р123).
/** Хосты, куда разрешено в этих проверках. Боевой список — в config/services.php. */
const HOSTY = ['render.local', '51-250-1-97.sslip.io'];
function dver(string $endpoint, string $key = 'secret', ?array $hosty = null): DverRendera
{
return new DverRendera($endpoint, $key, $hosty ?? HOSTY);
}
// ─────────────────────────────────────────────────────────────────────────────
// Две причины «нет настройки» — каждая СВОИМИ словами, а не одним «не готово»
// ─────────────────────────────────────────────────────────────────────────────
it('пустой адрес — закрыта, и сказано именно про адрес', function () {
expect(dver('')->pochemuZakryta())->toContain('не задан адрес');
});
it('пустой ключ — закрыта, и сказано именно про ключ', function () {
expect(dver('https://render.local/render', '')->pochemuZakryta())->toContain('не задан тайный ключ');
});
it('пробелы вокруг пустого адреса не считаются адресом', function () {
expect(dver(' ')->pochemuZakryta())->toContain('не задан адрес');
});
// ─────────────────────────────────────────────────────────────────────────────
// Разрешённые машины. Пустой список = закрыто ВСЁ
// ─────────────────────────────────────────────────────────────────────────────
it('пустой список разрешённых машин закрывает даже правильный адрес', function () {
expect(dver('https://render.local/render', 'secret', [])->pochemuZakryta())
->toContain('неразрешённый хост');
});
it('чужой хост закрыт, и хост назван вслух', function () {
expect(dver('https://render.local.zloumyshlennik.ru/render')->pochemuZakryta())
->toContain('render.local.zloumyshlennik.ru');
});
it('хост сверяется без оглядки на регистр букв', function () {
expect(dver('https://RENDER.LOCAL/render')->pochemuZakryta())->toBe('');
});
it('хост из списка, записанного заглавными, тоже узнаётся', function () {
expect(dver('https://render.local/render', 'secret', ['RENDER.LOCAL'])->pochemuZakryta())->toBe('');
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 ВОСЕМЬ ФОРМ АДРЕСА — та же приёмка, что прошла дверь голосового робота
// ─────────────────────────────────────────────────────────────────────────────
it('восемь форм адреса разбираются ровно так, как решил владелец', function (string $adres, bool $otkryta) {
expect(dver($adres)->pochemuZakryta() === '')->toBe($otkryta);
})->with([
'https обычный' => ['https://render.local/render', true],
'HTTPS большими буквами' => ['HTTPS://render.local/render', true],
'https с портом' => ['https://render.local:8790/render', true],
'https с пробелами по краям' => [' https://render.local/render ', true],
'http — ключ уехал бы открытым' => ['http://render.local/render', false],
'httpsX — не https' => ['httpsX://render.local/render', false],
'http, а https внутри пути' => ['http://render.local/https://render', false],
'вовсе без схемы' => ['render.local/render', false],
]);
it('боевой адрес виртуалки дверь ПРОПУСКАЕТ', function () {
expect(dver('https://51-250-1-97.sslip.io/render')->pochemuZakryta())->toBe('');
});
it('про http сказано именно «без шифрования», а не «чужой хост»', function () {
expect(dver('http://render.local/render')->pochemuZakryta())
->toContain('без шифрования')
->toContain('открытым текстом');
});
// ─────────────────────────────────────────────────────────────────────────────
// Своя машина — ДВА независимых ответа, и оба обязаны совпасть
// ─────────────────────────────────────────────────────────────────────────────
// 🪤 IPv6 в списке разрешённых пишется В СКОБКАХ — ровно так его отдаёт разбор адреса.
// Замерено: без скобок хост не узнаётся. Так же устроена и проверенная дверь голосового
// робота; расхождение между двумя дверями было бы хуже этой шероховатости.
it('на своей машине http на петлю разрешён', function (string $adres) {
expect(dver($adres, 'secret', ['127.0.0.1', 'localhost', '127.0.1.1', '[::1]', '[::ffff:127.0.0.1]', 'moy.localhost'])
->pochemuZakryta())->toBe('');
})->with([
'http://127.0.0.1:8790/render',
'http://127.0.1.1:8790/render',
'http://localhost:8790/render',
'http://moy.localhost:8790/render',
'http://[::1]:8790/render',
'http://[::ffff:127.0.0.1]:8790/render',
]);
it('localhost внутри чужого имени петлёй НЕ считается', function () {
expect(dver('http://localhost.chuzhoy.ru/render', 'secret', ['localhost.chuzhoy.ru'])->pochemuZakryta())
->toContain('без шифрования');
});
it('на боевом окружении петля НЕ спасает — http закрыт', function () {
app()->detectEnvironment(fn () => 'production');
expect(dver('http://127.0.0.1:8790/render', 'secret', ['127.0.0.1'])->pochemuZakryta())
->toContain('без шифрования');
});
it('на боевом окружении https на разрешённый хост по-прежнему открыт', function () {
app()->detectEnvironment(fn () => 'production');
expect(dver('https://51-250-1-97.sslip.io/render')->pochemuZakryta())->toBe('');
});
// ─────────────────────────────────────────────────────────────────────────────
// Мягкая деградация клиента: наружу НЕ ходим, отдаём пусто, причина в журнал
// ─────────────────────────────────────────────────────────────────────────────
function klientSDveryu(string $endpoint, array $hosty = HOSTY): SelfRenderClient
{
return new SelfRenderClient(
endpoint: $endpoint,
key: 'secret',
concurrency: 4,
retries: 2,
razreshyonnyeHosty: $hosty,
);
}
it('закрытая дверь: html не ходит наружу и отдаёт пусто', function () {
Http::fake();
expect(klientSDveryu('http://render.local/render')->html('https://2gis.ru/x'))->toBe('');
Http::assertNothingSent();
});
it('закрытая дверь: htmlBatch не ходит наружу и отдаёт пусто на каждый адрес', function () {
Http::fake();
$out = klientSDveryu('https://chuzhoy.ru/render')->htmlBatch(['https://2gis.ru/a', 'https://2gis.ru/b']);
expect($out)->toBe(['https://2gis.ru/a' => '', 'https://2gis.ru/b' => '']);
Http::assertNothingSent();
});
it('закрытая дверь пишет причину в журнал уровнем не ниже предупреждения', function () {
Http::fake();
Log::shouldReceive('warning')->once()->withArgs(
fn (string $soobshchenie) => str_contains($soobshchenie, 'без шифрования')
);
klientSDveryu('http://render.local/render')->html('https://2gis.ru/x');
});
it('в журнал не попадает тайный ключ', function () {
Http::fake();
Log::shouldReceive('warning')->once()->withArgs(
fn (string $soobshchenie, array $context = []) => ! str_contains($soobshchenie.json_encode($context), 'secret')
);
klientSDveryu('http://render.local/render')->html('https://2gis.ru/x');
});
it('открытая дверь по-прежнему ходит наружу — сегодняшняя работа не изменилась', function () {
Http::fake(['https://render.local/render' => Http::response(['data' => '{"state":1}'], 200)]);
expect(klientSDveryu('https://render.local/render')->html('https://2gis.ru/x'))->toBe('{"state":1}');
Http::assertSent(fn ($r) => $r->hasHeader('X-Render-Key', 'secret'));
});
// ─────────────────────────────────────────────────────────────────────────────
// Вторая дверь на том же ключе — лента Яндекс.Карт
// ─────────────────────────────────────────────────────────────────────────────
it('лента Яндекса: закрытая дверь не ходит наружу и отдаёт пусто', function () {
Http::fake();
$istochnik = new RenderServiceYandexListSource(
endpoint: 'http://render.local/render',
key: 'secret',
httpTimeout: 5,
razreshyonnyeHosty: HOSTY,
);
expect($istochnik->fetch('https://yandex.ru/maps/x', 2))->toBe([]);
Http::assertNothingSent();
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴🔴 ГЛАВНОЕ: сегодняшний БОЕВОЙ поиск обязан продолжать работать
//
// Эти проверки не выдумывают список хостов, а берут НАСТОЯЩЕЕ умолчание из
// config/services.php — то самое, что поедет на боевой. Если кто-нибудь опустошит
// умолчание или уберёт передачу списка из провайдера, поиск клиентов на боевом встанет
// в момент выката. Здесь это станет красным ДО выката, а не после.
// ─────────────────────────────────────────────────────────────────────────────
/** Боевой адрес виртуалки-рендера. Замер: ПИЛОТ.md 06.07.2026, «прод её достаёт». */
const BOEVOY = 'https://51-250-1-97.sslip.io/render';
it('умолчание разрешённых машин НЕ пусто — иначе боевой поиск встал бы в момент выката', function () {
expect((array) config('services.self_render.allowed_hosts'))->not->toBeEmpty();
});
it('боевой адрес виртуалки проходит дверь на НАСТОЯЩЕМ умолчании настроек', function () {
expect(
(new DverRendera(BOEVOY, 'secret', (array) config('services.self_render.allowed_hosts')))
->pochemuZakryta()
)->toBe('');
});
it('боевой адрес открыт и тогда, когда приложение считает себя боевым', function () {
app()->detectEnvironment(fn () => 'production');
expect(
(new DverRendera(BOEVOY, 'secret', (array) config('services.self_render.allowed_hosts')))
->pochemuZakryta()
)->toBe('');
});
it('провайдер ДЕЙСТВИТЕЛЬНО отдаёт список разрешённых машин ленте Яндекса', function () {
config()->set('autopodbor.self_render', true);
config()->set('services.self_render.endpoint', BOEVOY);
config()->set('services.self_render.key', 'secret');
app()->detectEnvironment(fn () => 'production');
Http::fake([BOEVOY => Http::response(['data' => '{"orgs":[]}'], 200)]);
(new AutopodborServiceProvider(app()))->yandexListSource()
->fetch('https://yandex.ru/maps/?text=ломбард', 1);
// Запрос УШЁЛ — значит провайдер передал список и дверь открылась на боевом адресе.
Http::assertSent(fn ($r) => $r->url() === BOEVOY && $r->hasHeader('X-Render-Key', 'secret'));
});
it('лента Яндекса: открытая дверь по-прежнему ходит наружу', function () {
Http::fake(['https://render.local/render' => Http::response(['data' => '{"orgs":[{"name":"А"}]}'], 200)]);
$istochnik = new RenderServiceYandexListSource(
endpoint: 'https://render.local/render',
key: 'secret',
httpTimeout: 5,
razreshyonnyeHosty: HOSTY,
);
expect($istochnik->fetch('https://yandex.ru/maps/x', 2))->toBe([['name' => 'А']]);
Http::assertSent(fn ($r) => $r->hasHeader('X-Render-Key', 'secret'));
});