7aa3083399
Человек, получивший СМС, теперь может отказаться сам: короткая ссылка 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>
60 lines
2.4 KiB
PHP
60 lines
2.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ClientSmsUnsubscribeLink;
|
|
use App\Models\Tenant;
|
|
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
/**
|
|
* Строка листа 1.9: ссылка на один и тот же номер у одного клиента всегда одна
|
|
* и та же. Иначе при каждой рассылке одному человеку уезжала бы новая ссылка,
|
|
* а старые копились бы вечно.
|
|
*
|
|
* Синтетические номера 7999… — реальные НИКОГДА.
|
|
*/
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->tenant = Tenant::factory()->create();
|
|
$this->otherTenant = Tenant::factory()->create();
|
|
$this->service = app(ClientSmsUnsubscribeLinkService::class);
|
|
});
|
|
|
|
it('токен на пару «клиент + номер» постоянный', function () {
|
|
$first = $this->service->tokenFor($this->tenant->id, '79990000001');
|
|
$second = $this->service->tokenFor($this->tenant->id, '79990000001');
|
|
|
|
expect($second)->toBe($first)
|
|
->and(strlen($first))->toBe(12)
|
|
->and(ClientSmsUnsubscribeLink::count())->toBe(1);
|
|
});
|
|
|
|
it('у разных клиентов на один номер разные ссылки', function () {
|
|
$mine = $this->service->tokenFor($this->tenant->id, '79990000001');
|
|
$theirs = $this->service->tokenFor($this->otherTenant->id, '79990000001');
|
|
|
|
expect($theirs)->not->toBe($mine)
|
|
->and(ClientSmsUnsubscribeLink::count())->toBe(2);
|
|
});
|
|
|
|
it('по токену находится тот самый клиент и тот самый номер', function () {
|
|
$token = $this->service->tokenFor($this->tenant->id, '79990000002');
|
|
|
|
$link = ClientSmsUnsubscribeLink::findOrFail($token);
|
|
|
|
expect($link->tenant_id)->toBe($this->tenant->id)
|
|
->and($link->phone)->toBe('79990000002');
|
|
});
|
|
|
|
it('в токене нет букв и цифр, которые путаются при чтении вслух', function () {
|
|
$tokens = [];
|
|
foreach (range(1, 20) as $i) {
|
|
$tokens[] = $this->service->tokenFor($this->tenant->id, '7999000'.str_pad((string) $i, 4, '0', STR_PAD_LEFT));
|
|
}
|
|
|
|
// 0/O/o, 1/l/I — человек диктует ссылку по телефону, путаница = чужая страница
|
|
expect(implode('', $tokens))->not->toMatch('/[0OolI1]/');
|
|
});
|