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>
74 lines
3.0 KiB
PHP
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);
|
|
});
|