feat,смс: приёмник статусов Билайна — платформа сама сообщает судьбу
Билайн умеет звонить нам сам: POST на /api/webhook/beeline-delivery/{secret}
с полями ORDID, CNRID, RESCOUNT, STATUS, FINALTIME. Раньше судьбу сообщения
добирал только опрос — теперь она приходит сразу, как у МТС и Теле2.
Защита та же, что у двух соседних приёмников: секрет короче 32 знаков или
пустой = приёмник закрыт наглухо; сверка секрета через hash_equals; список
адресов отправителя необязательный, пустой список объявляется в журнале;
ограничитель 600 обращений в минуту; на чужой адрес и на неверный секрет
отвечаем 404, а не 403, чтобы не подсказывать чужому, что дверь тут есть.
Записи в журнал уровня warning, не notice: на бою LOG_LEVEL=warning и всё,
что ниже, не доезжает вовсе.
12 тестов написаны до кода, каждый посмотрен красным. Защита проверена
подсовыванием ошибки — краснеют ровно те тесты, что должны. Модуль целиком
530/530, pint чисто, статанализ 0.
Адрес приёмника в кабинете Билайна ещё не заявлен и секрет на бою не задан.
Пока их нет, приёмник закрыт, а канал работает по-старому через опрос.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,15 @@ SMS_BEELINE_PASSWORD=
|
||||
SMS_BEELINE_TOKEN=
|
||||
SMS_BEELINE_NAMING=
|
||||
SMS_BEELINE_PRICE_KOP=0
|
||||
# Обратный звонок Билайна о судьбе: POST /api/webhook/beeline-delivery/{секрет}
|
||||
# с полями ORDID, CNRID, RESCOUNT, STATUS, FINALTIME. Секрет — не короче 32 знаков;
|
||||
# пустой = приёмник ЗАКРЫТ (судьбу доберёт опрос). Сам адрес надо заявить в кабинете:
|
||||
# Настройки → Дополнительно → «Приём статусов отправленных сообщений».
|
||||
# 🪤 Повторяет ли платформа неудавшийся звонок — в её описании не сказано ни слова:
|
||||
# считаем, что пропущенный звонок потерян.
|
||||
# Адреса отправителя — через запятую, можно подсетями; пусто = не проверяем.
|
||||
SMS_BEELINE_WEBHOOK_SECRET=
|
||||
SMS_BEELINE_WEBHOOK_IPS=
|
||||
|
||||
# Реклама в Телеграме по своей базе (клиентский модуль, робот-в-браузере — у МТС нет API).
|
||||
# Песочница по умолчанию ВКЛ — робот доводит только до черновика, деньги не списываются.
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ClientSms\ClientSmsDeliveryWriter;
|
||||
use App\Services\Sms\SmsDeliveryPayloadParser;
|
||||
use App\Services\Sms\SmsRouter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Обратный звонок Билайна о судьбе сообщения (строка листа 5.2).
|
||||
*
|
||||
* URL: POST /api/webhook/beeline-delivery/{secret}
|
||||
* Поля: ORDID (номер сообщения), CNRID, RESCOUNT (частей к тарификации),
|
||||
* STATUS (судьба ЧИСЛОМ), FINALTIME (в UTC).
|
||||
*
|
||||
* 🔴 **Форма третья, не как у МТС и не как у Теле2.** Описание платформы прямо
|
||||
* говорит: «немедленно перешлёт на указанный URL по протоколу HTTPS методом POST».
|
||||
* Отсюда один глагол, а не два, как у t2: там форма живьём известна не была, здесь
|
||||
* названа. Окажется на деле иначе — расширяется одной строкой в маршрутах, и потеря
|
||||
* не страшна: судьбу доберёт опрос.
|
||||
*
|
||||
* 🔴 **Это НАДСТРОЙКА над опросом** `client-sms:poll-delivery`, а не замена.
|
||||
* Повторяет ли платформа неудавшийся звонок — в её описании не сказано ни слова,
|
||||
* поэтому считаем худшее: пропущенный звонок потерян навсегда. Значит любой отказ
|
||||
* приёмника безобиден, и везде здесь выбран ноль вместо догадки.
|
||||
*
|
||||
* 🔴 **Содержимое звонка в журнал сервера НЕ пишем**: телефона в нём нет вовсе, но
|
||||
* привычка та же, что у соседей — только перечень имён полей.
|
||||
*
|
||||
* 🔴 **Деньги отсюда не двигаются вовсе.** Возврат за недоставленное живёт в команде
|
||||
* опроса, у него своя двойная защита от повтора (решение владельца В-198).
|
||||
*
|
||||
* Защита — та же, что у приёмников МТС и Теле2 (публичный адрес в интернете):
|
||||
* 1. `{secret}` в адресе, сравнение `hash_equals`, короче 32 знаков не принимаем.
|
||||
* Секрет не задан = приёмник закрыт наглухо, а не «пускать всех».
|
||||
* 2. Список разрешённых адресов отправителя (`SMS_BEELINE_WEBHOOK_IPS`, через
|
||||
* запятую, можно подсетями). 🪤 Пока ПУСТ: адреса платформы нам неизвестны,
|
||||
* выдумывать нельзя. Пустой список = не проверяем, защита держится на секрете.
|
||||
* 3. Счётчик обращений с адреса — от потока даже с верным секретом.
|
||||
*
|
||||
* Несовпадение секрета или адреса → **404**: существование приёмника чужому не
|
||||
* подтверждаем.
|
||||
*/
|
||||
class BeelineDeliveryWebhookController extends Controller
|
||||
{
|
||||
/** Ключ канала, чьи отчёты принимает этот адрес. */
|
||||
private const PROVIDER_KEY = 'beeline';
|
||||
|
||||
/** Обращений в минуту с одного адреса. Скорость потока платформы нам
|
||||
* неизвестна — берём ту же меру, что у МТС и t2: она с запасом покрывает
|
||||
* наши объёмы. */
|
||||
private const RATE_LIMIT_PER_MINUTE = 600;
|
||||
|
||||
public function receive(
|
||||
Request $request,
|
||||
string $secret,
|
||||
SmsRouter $router,
|
||||
ClientSmsDeliveryWriter $writer,
|
||||
): Response|JsonResponse {
|
||||
if (! $this->verifySecret($secret)) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
if (! $this->verifyIpAllowlist($request->ip())) {
|
||||
Log::warning('client_sms.delivery_hook_foreign_ip', [
|
||||
'provider' => self::PROVIDER_KEY,
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$this->noteOpenGate($request->ip());
|
||||
|
||||
$rateKey = 'beeline-delivery-hook:'.($request->ip() ?? 'unknown');
|
||||
if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) {
|
||||
$retryAfter = RateLimiter::availableIn($rateKey);
|
||||
|
||||
return response()->json(['message' => 'Превышен лимит запросов.'], 429)
|
||||
->header('Retry-After', (string) $retryAfter);
|
||||
}
|
||||
RateLimiter::hit($rateKey, 60);
|
||||
|
||||
$parser = $this->parser($router);
|
||||
|
||||
if ($parser === null) {
|
||||
// Канал не поднят (выключен или песочница) — разбирать звонок нечем.
|
||||
// Молчать нельзя (урок В-121), но и отказывать платформе незачем.
|
||||
Log::warning('client_sms.delivery_hook_no_channel', ['provider' => self::PROVIDER_KEY]);
|
||||
|
||||
return $this->accepted();
|
||||
}
|
||||
|
||||
// Тело и строки запроса разом: поля платформа шлёт формой, но брать только
|
||||
// тело значило бы гадать о её привычках.
|
||||
/** @var array<mixed> $params */
|
||||
$params = $request->all();
|
||||
|
||||
$reports = $parser->parseDeliveryPayload($params);
|
||||
|
||||
if ($reports === []) {
|
||||
// Звонок не узнан. Пишем его ФОРМУ — перечень имён полей, без значений.
|
||||
Log::warning('client_sms.delivery_hook_unparsed', [
|
||||
'provider' => self::PROVIDER_KEY,
|
||||
'keys' => array_keys($params),
|
||||
'size' => count($params),
|
||||
]);
|
||||
|
||||
return $this->accepted();
|
||||
}
|
||||
|
||||
$updated = $writer->applyMany(self::PROVIDER_KEY, $reports);
|
||||
|
||||
// «Успех с нулём» — не повод молчать (урок В-121): пришёл отчёт про
|
||||
// сообщение, которого мы не знаем.
|
||||
if ($updated === 0) {
|
||||
Log::warning('client_sms.delivery_hook_unknown_messages', [
|
||||
'provider' => self::PROVIDER_KEY,
|
||||
'reports' => count($reports),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->accepted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Чего платформа ждёт в ответ, её описание не говорит — отдаём самое дешёвое:
|
||||
* 204, пустое тело. Тот же ответ, что у приёмников МТС и t2: три приёмника,
|
||||
* одна привычка.
|
||||
*/
|
||||
private function accepted(): Response
|
||||
{
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function notFound(): JsonResponse
|
||||
{
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
/** Канал, умеющий разобрать звонок о судьбе. Нет такого — null. */
|
||||
private function parser(SmsRouter $router): ?SmsDeliveryPayloadParser
|
||||
{
|
||||
foreach ($router->providers() as $provider) {
|
||||
if ($provider instanceof SmsDeliveryPayloadParser && $provider->key() === self::PROVIDER_KEY) {
|
||||
return $provider;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function verifySecret(string $provided): bool
|
||||
{
|
||||
$expected = (string) config('services.sms.beeline.webhook_secret', '');
|
||||
|
||||
// Не задан или слишком короток — приёмник считается ненастроенным и не
|
||||
// работает вовсе. Это НЕ «пускать всех»: адрес публичный, а за ним деньги.
|
||||
if (strlen($expected) < 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($expected, $provided);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пока белый список пуст, отказа никому нет — а значит, и записи, с какого
|
||||
* адреса звонит платформа, взяться неоткуда: заполнить список было бы нечем.
|
||||
* Разрываем круг — пишем адрес звонившего. Как только список задан, смолкает.
|
||||
*
|
||||
* 🪤 Уровень именно «предупреждение», а не «к сведению»: на боевом
|
||||
* LOG_LEVEL=warning, и всё, что ниже, молча выбрасывается. Написанная
|
||||
* уровнем ниже строка не попала бы в журнал НИКОГДА — круг остался бы
|
||||
* замкнут, только теперь незаметно. Замерено живым вызовом на бою
|
||||
* 04.08.2026: соседнее предупреждение легло, а «к сведению» — нет.
|
||||
*/
|
||||
private function noteOpenGate(?string $clientIp): void
|
||||
{
|
||||
if (trim((string) config('services.sms.beeline.webhook_ips', '')) !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::warning('client_sms.delivery_hook_open_gate', [
|
||||
'provider' => self::PROVIDER_KEY,
|
||||
'ip' => $clientIp,
|
||||
]);
|
||||
}
|
||||
|
||||
private function verifyIpAllowlist(?string $clientIp): bool
|
||||
{
|
||||
$raw = trim((string) config('services.sms.beeline.webhook_ips', ''));
|
||||
|
||||
// Список пуст — адреса платформы нам ещё неизвестны, проверять нечем.
|
||||
// Защита держится на секрете; список включается одной строкой в `.env`.
|
||||
if ($raw === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($clientIp === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$list = array_values(array_filter(array_map(trim(...), explode(',', $raw))));
|
||||
|
||||
return $list === [] || IpUtils::checkIp($clientIp, $list);
|
||||
}
|
||||
}
|
||||
@@ -411,6 +411,17 @@ return [
|
||||
'enabled' => (bool) env('SMS_BEELINE_ENABLED', false),
|
||||
'base_url' => env('SMS_BEELINE_BASE_URL', 'https://a2p-sms-https.beeline.ru/proto/http/rest'),
|
||||
'naming' => env('SMS_BEELINE_NAMING', ''),
|
||||
// Обратный звонок о судьбе (строка листа 5.2): платформа сама зовёт
|
||||
// /api/webhook/beeline-delivery/{secret} методом POST, поля ORDID,
|
||||
// CNRID, RESCOUNT, STATUS, FINALTIME. Адрес заявляется в кабинете:
|
||||
// Настройки → Дополнительно → «Приём статусов отправленных сообщений».
|
||||
// Секрет короче 32 знаков или пустой = приёмник ЗАКРЫТ наглухо.
|
||||
// 🪤 Повторяет ли платформа неудавшийся звонок — в её описании НЕ
|
||||
// сказано ни слова; считаем худшее, пропущенный доберёт только опрос.
|
||||
// Адреса отправителя — через запятую, можно подсетями; пуст = не
|
||||
// проверяем (адреса платформы нам неизвестны, выдумывать нельзя).
|
||||
'webhook_secret' => env('SMS_BEELINE_WEBHOOK_SECRET', ''),
|
||||
'webhook_ips' => env('SMS_BEELINE_WEBHOOK_IPS', ''),
|
||||
],
|
||||
// Канал Теле2 = «SMS-Таргет» (target.t2.ru), HTTP API v2, вход по логину и
|
||||
// паролю (Basic Auth) из раздела кабинета «Профиль → API-рассылки».
|
||||
|
||||
@@ -691,6 +691,18 @@ Route::post('/api/webhook/mts-delivery/{secret}', 'App\Http\Controllers\Api\MtsD
|
||||
Route::match(['get', 'post'], '/api/webhook/t2-delivery/{secret}', 'App\Http\Controllers\Api\T2DeliveryWebhookController@receive')
|
||||
->where('secret', '[A-Za-z0-9_\-]+');
|
||||
|
||||
// Обратный звонок Билайна о судьбе СМС (Этап 5, строка листа 5.2). Форма ТРЕТЬЯ:
|
||||
// описание платформы прямо называет POST по HTTPS, поля ORDID / CNRID / RESCOUNT /
|
||||
// STATUS / FINALTIME, судьба ЧИСЛОМ, время в UTC. Отсюда один глагол, а не два,
|
||||
// как у t2: там форма живьём известна не была, здесь названа. Защита та же: секрет
|
||||
// в адресе (≥32 знаков) + необязательный список адресов отправителя.
|
||||
// Адрес заявляется в кабинете: Настройки → Дополнительно → «Приём статусов
|
||||
// отправленных сообщений».
|
||||
// 🪤 Повторяет ли платформа неудавшийся звонок — в её описании НЕ сказано,
|
||||
// поэтому опрос `client-sms:poll-delivery` здесь тем более обязателен.
|
||||
Route::post('/api/webhook/beeline-delivery/{secret}', 'App\Http\Controllers\Api\BeelineDeliveryWebhookController@receive')
|
||||
->where('secret', '[A-Za-z0-9_\-]+');
|
||||
|
||||
// Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt.
|
||||
// Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7.
|
||||
Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive');
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdWalletTransaction;
|
||||
use App\Models\ClientSmsCampaign;
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Sms\Providers\BeelineSmsProvider;
|
||||
use App\Services\Sms\SmsDeliveryState;
|
||||
use App\Services\Sms\SmsRouter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* Строка листа 5.2 для Билайна: платформа сама зовёт нас, как только узнает судьбу.
|
||||
*
|
||||
* 🔴 Форма ТРЕТЬЯ, не как у МТС и не как у Теле2: описание платформы прямо говорит
|
||||
* «немедленно перешлёт на указанный URL по протоколу HTTPS **методом POST**», поля —
|
||||
* `ORDID`, `CNRID`, `RESCOUNT`, `STATUS`, `FINALTIME`. Судьба приезжает ЧИСЛОМ,
|
||||
* а не словом, и время — в UTC. Разбор этого уже живёт в канале и покрыт своими
|
||||
* тестами; здесь проверяется сама ручка.
|
||||
*
|
||||
* 🔴 Это НАДСТРОЙКА над опросом `client-sms:poll-delivery`, а не замена. Повторяет
|
||||
* ли платформа неудавшийся звонок — в описании не сказано ни слова, поэтому считаем
|
||||
* худшее: пропущенный звонок потерян навсегда. Отсюда правило — любой отказ приёмника
|
||||
* безобиден, всё доберёт ближайший заход опроса.
|
||||
*
|
||||
* 🪤 SharesSupplierPdo обязателен: приёмник кросс-клиентский и правит журнал
|
||||
* СЛУЖЕБНЫМ соединением. Без общего PDO оно не видит незакоммиченных данных теста
|
||||
* и находит НОЛЬ — прогон позеленел бы ВРУЩИ (урок В-95).
|
||||
*
|
||||
* Помощники с префиксом beeHook* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
||||
* Телефоны только синтетические 7999… — реальные НИКОГДА.
|
||||
*/
|
||||
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
||||
|
||||
/** Секрет приёмника: поедет в адресе, короче 32 знаков не принимаем. */
|
||||
const BEE_HOOK_SECRET = 'beeline-delivery-secret-0123456789';
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-08-15 12:00:00');
|
||||
|
||||
config(['services.sms.beeline.webhook_secret' => BEE_HOOK_SECRET]);
|
||||
config(['services.sms.beeline.webhook_ips' => '']);
|
||||
|
||||
// Разбор живёт в КАНАЛЕ. В песочнице маршрутизатор держит только заглушку,
|
||||
// поэтому канал Билайна ставим руками — в сеть он здесь не ходит.
|
||||
app()->instance(SmsRouter::class, new SmsRouter([
|
||||
new BeelineSmsProvider('login-not-used-here', 'pass', ['beeline'], ['*' => 475]),
|
||||
]));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
function beeHookUrl(string $secret = BEE_HOOK_SECRET): string
|
||||
{
|
||||
return '/api/webhook/beeline-delivery/'.$secret;
|
||||
}
|
||||
|
||||
function beeHookMessage(string $providerMessageId = '508909732338141600334', string $phone = '79990000001'): ClientSmsMessage
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$campaign = ClientSmsCampaign::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'title' => 'Отчёт от Билайна',
|
||||
'body' => 'Текст',
|
||||
'sender_name' => 'Liderra.ru',
|
||||
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
||||
'status' => ClientSmsCampaign::STATUS_DONE,
|
||||
'idempotency_key' => 'bee-hook-'.uniqid(),
|
||||
'segments' => 1,
|
||||
'planned_count' => 1,
|
||||
'sent_count' => 1,
|
||||
'total_sms' => 1,
|
||||
'price_rub_per_sms' => '4.75',
|
||||
'estimated_cost_rub' => '4.75',
|
||||
]);
|
||||
|
||||
return ClientSmsMessage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'campaign_id' => $campaign->id,
|
||||
'phone' => $phone,
|
||||
'operator' => 'beeline',
|
||||
'provider_key' => 'beeline',
|
||||
'status' => ClientSmsMessage::STATUS_SENT,
|
||||
'provider_message_id' => $providerMessageId,
|
||||
'cost_rub' => '4.75',
|
||||
'segments' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
it('принимает звонок Билайна, правит строку журнала и отвечает без содержимого', function () {
|
||||
$message = beeHookMessage();
|
||||
|
||||
$response = $this->post(beeHookUrl(), [
|
||||
'ORDID' => '508909732338141600334',
|
||||
'CNRID' => '1046802',
|
||||
'RESCOUNT' => '2',
|
||||
'STATUS' => '2',
|
||||
'FINALTIME' => '15.08.2026 09:30:00',
|
||||
]);
|
||||
|
||||
$response->assertNoContent();
|
||||
|
||||
$message->refresh();
|
||||
expect($message->delivery_status)->toBe(SmsDeliveryState::DELIVERED)
|
||||
->and($message->delivery_raw)->toBe('2')
|
||||
->and($message->provider_parts)->toBe(2)
|
||||
->and($message->delivered_at)->not->toBeNull()
|
||||
->and($message->delivery_checked_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('«оператор не принял» — это НЕ доставлено, а не ушло вовсе', function () {
|
||||
// За неотправленное клиент платить не должен (В-212): судьбы «не доставлено»
|
||||
// и «не ушло» разные, и возврат за них считается по-разному.
|
||||
$message = beeHookMessage();
|
||||
|
||||
$this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '8'])
|
||||
->assertNoContent();
|
||||
|
||||
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_SENT);
|
||||
});
|
||||
|
||||
it('«судьба неизвестна» судьбу НЕ проставляет', function () {
|
||||
// 7 — платформа сама не знает. Это не «не дошло»; догадка тут стоила бы
|
||||
// ложного возврата денег.
|
||||
$message = beeHookMessage();
|
||||
|
||||
$this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '7'])
|
||||
->assertNoContent();
|
||||
|
||||
$message->refresh();
|
||||
expect($message->delivery_status)->toBeNull()
|
||||
->and($message->delivery_raw)->toBe('7');
|
||||
});
|
||||
|
||||
it('не пускает чужой адрес и журнал не трогает', function () {
|
||||
config(['services.sms.beeline.webhook_ips' => '203.0.113.10, 203.0.113.11']);
|
||||
$message = beeHookMessage();
|
||||
|
||||
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
|
||||
->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2']);
|
||||
|
||||
// 404, а не 403: существование адреса чужому не подтверждаем.
|
||||
$response->assertNotFound();
|
||||
expect($message->refresh()->delivery_status)->toBeNull();
|
||||
});
|
||||
|
||||
it('пускает адрес из списка разрешённых', function () {
|
||||
config(['services.sms.beeline.webhook_ips' => '203.0.113.10, 198.51.100.0/24']);
|
||||
$message = beeHookMessage();
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
|
||||
->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2'])
|
||||
->assertNoContent();
|
||||
|
||||
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED);
|
||||
});
|
||||
|
||||
/**
|
||||
* Пока белый список пуст, ворота открыты всем — и адрес звонившего негде взять:
|
||||
* отказа нет, значит и записи «пришли с такого-то адреса» нет. Круг замкнут,
|
||||
* список не заполнить никогда. Разрываем его: при пустом списке пишем адрес.
|
||||
*/
|
||||
it('при пустом белом списке записывает адрес звонившего', function () {
|
||||
config(['services.sms.beeline.webhook_ips' => '']);
|
||||
Log::spy();
|
||||
beeHookMessage();
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77'])
|
||||
->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2'])
|
||||
->assertNoContent();
|
||||
|
||||
// Уровень именно «предупреждение». На боевом LOG_LEVEL=warning, и всё, что
|
||||
// ниже, молча выбрасывается: записанная уровнем «к сведению» строка не
|
||||
// появилась бы НИКОГДА — замерено живым вызовом на бою 04.08.2026.
|
||||
/* @phpstan-ignore-next-line staticMethod.notFound */
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn ($msg, $ctx) => $msg === 'client_sms.delivery_hook_open_gate'
|
||||
&& $ctx['provider'] === 'beeline'
|
||||
&& $ctx['ip'] === '203.0.113.77')
|
||||
->once();
|
||||
});
|
||||
|
||||
it('когда белый список задан — про адрес молчит', function () {
|
||||
config(['services.sms.beeline.webhook_ips' => '203.0.113.0/24']);
|
||||
Log::spy();
|
||||
beeHookMessage();
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77'])
|
||||
->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2'])
|
||||
->assertNoContent();
|
||||
|
||||
// Молчит именно про адрес: прочие предупреждения приёмника этот тест не трогает.
|
||||
/* @phpstan-ignore-next-line staticMethod.notFound */
|
||||
Log::shouldNotHaveReceived('warning', [
|
||||
'client_sms.delivery_hook_open_gate',
|
||||
Mockery::any(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('не пускает с неверным секретом и журнал не трогает', function () {
|
||||
$message = beeHookMessage();
|
||||
|
||||
$response = $this->post(
|
||||
beeHookUrl('sovsem-drugoy-sekret-0123456789abcd'),
|
||||
['ORDID' => '508909732338141600334', 'STATUS' => '2'],
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
expect($message->refresh()->delivery_status)->toBeNull();
|
||||
});
|
||||
|
||||
it('закрыт наглухо, пока секрет не задан', function () {
|
||||
config(['services.sms.beeline.webhook_secret' => '']);
|
||||
$message = beeHookMessage();
|
||||
|
||||
// Пустой секрет — это «приёмник не настроен», а не «пускать всех».
|
||||
$response = $this->post(
|
||||
beeHookUrl('chto-ugodno-0123456789abcdefghij'),
|
||||
['ORDID' => '508909732338141600334', 'STATUS' => '2'],
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
expect($message->refresh()->delivery_status)->toBeNull();
|
||||
});
|
||||
|
||||
it('на звонок про незнакомое сообщение не падает', function () {
|
||||
$message = beeHookMessage(providerMessageId: '508909732338141600334');
|
||||
|
||||
$this->post(beeHookUrl(), ['ORDID' => '999999999999999999999', 'STATUS' => '2'])
|
||||
->assertNoContent();
|
||||
|
||||
expect($message->refresh()->delivery_status)->toBeNull();
|
||||
});
|
||||
|
||||
it('на звонок непонятной формы не пишет ничего', function () {
|
||||
$message = beeHookMessage();
|
||||
|
||||
$this->post(beeHookUrl(), ['message_uid' => '508909732338141600334', 'state' => 'ok'])
|
||||
->assertNoContent();
|
||||
|
||||
$message->refresh();
|
||||
expect($message->delivery_status)->toBeNull()
|
||||
->and($message->delivery_raw)->toBeNull()
|
||||
->and($message->delivery_checked_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('денег НЕ двигает — возврат остаётся делом опроса', function () {
|
||||
$message = beeHookMessage();
|
||||
|
||||
$this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '5'])
|
||||
->assertNoContent();
|
||||
|
||||
$message->refresh();
|
||||
expect($message->delivery_status)->toBe(SmsDeliveryState::NOT_DELIVERED)
|
||||
->and($message->refunded_at)->toBeNull()
|
||||
// Деньги живут в ОДНОМ доме — в команде опроса (В-146).
|
||||
// 🪤 Считаем проводки СВОЕГО клиента: рекламные тесты идут без отката и
|
||||
// оставляют свои записи в базе (урок из MtsDeliveryWebhookTest).
|
||||
->and(AdWalletTransaction::where('tenant_id', $message->tenant_id)->count())->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user