Files
portal/app/tests/Feature/ClientSms/PublicUnsubscribeTest.php
T
Дмитрий 7aa3083399 feat(смс-клиент): страница отказа по ссылке из СМС — без входа, номер маской, с лимитом
Человек, получивший СМС, теперь может отказаться сам: короткая ссылка
liderra.ru/s/<токен> открывает простую страницу с одной кнопкой. Нажал — номер
в стоп-листе именно той компании, от которой пришло сообщение.

- таблица client_sms_unsubscribe_links (RLS + tenant_isolation), одна ссылка
  на пару «клиент + номер» навсегда — при каждой рассылке новая не плодится
- сервис токенов: 12 символов, без 0/O/o и 1/l/I (ссылку диктуют вслух)
- публичная страница: blade без Vue-сборки, номер только маской +7 *** *** ** 67
- ограничение частоты 20/мин с IP — перебор ссылок иначе = утечка номеров;
  защита проверена вырезанием, тест на 429 краснеет
- неизвестная ссылка отвечает 404 без подсказок
- служебное соединение только в этом контроллере (тенант-контекста у страницы
  нет) + GRANT для crm_supplier_worker на client_sms_optouts
- CHANGELOG схемы v9.02, с напоминанием ПЕРЕзапустить 03_service_bypass_policies.sql

Строки приёмочного листа 1.6, 1.7, 1.8, 1.9 закрыты тестами; живой прогон —
после выката, страницу надо открыть телефоном.

Ветка feat/client-sms-broadcast, песочница, в main не влито.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:58:56 +03:00

74 lines
3.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientSmsOptout;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строки листа 1.6, 1.7, 1.8: страница отказа по короткой ссылке из СМС.
*
* Первый маршрут модуля БЕЗ авторизации: получатель СМС — не наш пользователь.
* Отсюда три требования, каждое проверяется отдельно: номер только маской,
* перебор токенов упирается в ограничение частоты, неизвестный токен молчит.
*
* Синтетические номера 7999… — реальные НИКОГДА.
*/
// Страница ходит служебным соединением (тенант-контекста у неё нет) — в тестах
// оба соединения обязаны видеть одну транзакцию, иначе засеянная ссылка «не найдена».
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
RateLimiter::clear('sms-unsubscribe');
$this->tenant = Tenant::factory()->create();
$this->token = app(ClientSmsUnsubscribeLinkService::class)
->tokenFor($this->tenant->id, '79990000067');
});
it('человек по ссылке отказывается — и попадает в стоп-лист того клиента', function () {
$this->post("/s/{$this->token}")->assertRedirect();
$row = ClientSmsOptout::where('tenant_id', $this->tenant->id)
->where('phone', '79990000067')
->first();
expect($row)->not->toBeNull()
->and($row->source)->toBe(ClientSmsOptout::SOURCE_RECIPIENT);
});
it('повторный отказ по той же ссылке не ломается', function () {
$this->post("/s/{$this->token}")->assertRedirect();
$this->post("/s/{$this->token}")->assertRedirect();
expect(ClientSmsOptout::where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
it('страница не показывает номер целиком — только маской', function () {
$this->get("/s/{$this->token}")
->assertOk()
->assertDontSee('79990000067')
->assertSee('** 67', false);
});
it('неизвестный токен не выдаёт, существует он или нет', function () {
$this->get('/s/zzzzzzzzzzzz')->assertNotFound();
});
it('перебор ссылок упирается в ограничение частоты', function () {
$status = 200;
foreach (range(1, 30) as $i) {
$status = $this->get('/s/aaaaaaaaaaaa')->status();
if ($status === 429) {
break;
}
}
expect($status)->toBe(429);
});