272ccbffe0
Билайн умеет звонить нам сам: 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>
269 lines
12 KiB
PHP
269 lines
12 KiB
PHP
<?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);
|
||
});
|