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')); });