Files
portal/app/tests/Feature/ClientSms/MigrationGrantsTest.php
T
Дмитрий 36c8ced41e feat(смс-клиент): судьба сообщения отдельной графой + право служебной роли править журнал
Этап 5, Task 2. Схема v9.23 и v9.24.

Графа судьбы (миграция 101100): семь колонок у client_sms_messages —
delivery_status, delivered_at, delivery_checked_at, delivery_raw,
provider_cost, provider_parts, refunded_at, плюс индекс «кого спрашивать дальше».

Почему графа, а не переписывание status: по status считаются ДЕНЬГИ и месячный
объём клиента (ClientSmsVolumeCounter берёт ровно sent), его же складывает сторож
зависших и разбирают два словаря подписей на экране. Заменив sent на delivered,
мы обнулили бы клиенту накопление за месяц и удешевили бы цену задним числом.
Значит status остаётся фактом ПЕРЕДАЧИ, а судьба живёт рядом (В-202).

Цена оператора (provider_cost) хранится КАК ЕСТЬ: единицы поля cost у МТС
неизвестны (В-211), в рубли не переводится и ни во что не подставляется.

Право служебной роли (миграция 101200): GRANT UPDATE на client_sms_messages.
Команда опроса судьбы кросс-клиентская, ходит служебным соединением и ПРАВИТ
существующую строку — новых не пишет намеренно (В-204: новые строки делали бы
зависшую рассылку «живой» для сторожа, и деньги остались бы замороженными).
Без права на бою команда падала бы каждые десять минут; локально не видно
никогда — dev и тесты ходят суперпользователем (В-126/В-127).

Проверено:
- сторож MigrationGrantsTest расширен и проверен ВЫРЕЗОМ: гашение GRANT в
  миграции красит его;
- изоляция цела — запросом к базе после наката: RLS true/true, политика
  tenant_isolation на месте, права ролей ровно ожидаемые, все семь колонок есть;
- весь модуль 327/327 (было 320, +7 читателя судьбы), 13 пачек, все с первой
  попытки; phpstan ровно 2 чужие давние, pint чисто.
2026-07-31 06:04:56 +03:00

179 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Сторож прав рабочей роли на таблицы модуля.
*
* Зачем он появился (Task 8). В миграциях модуля права выдавались строкой
* `GRANT … TO crm_app_user` без проверки, есть ли такая роль: на чистой базе это
* валит `migrate` целиком. Проверку я добавил — но у неё есть своя опасность:
* опечатка в имени роли внутри проверки НЕ даёт ошибки, права просто молча не
* выдаются. А это ровно тот блокер выката, что стоил нам дня 27.07 (журнал В-36):
* на бою первая же запись падает с «нет доступа», а тесты и локальная база этого
* не видят — там суперпользователь.
*
* Поэтому здесь проверяется не текст миграции, а результат: реально ли у роли есть
* право писать в каждую таблицу модуля и пользоваться её счётчиком.
*
* ⚠️ Честное ограничение: тест доказывает это только там, где роли заведены
* (`db/00_create_roles.sql`). Где ролей нет — он пропускается и не доказывает ничего.
*/
uses(RefreshDatabase::class);
/** Таблица → права, которые модуль обещает рабочей роли. */
const CLIENT_SMS_TABLE_GRANTS = [
'client_sms_campaigns' => ['SELECT', 'INSERT', 'UPDATE'],
// UPDATE у снимка и у базы контактов добавлен при приёмке Этапа 3 (журнал В-126,
// В-127). Этап 3 научил джобы обогащения ПРАВИТЬ эти таблицы — проставлять
// найденный регион, пояс и оператора, — а права остались от старого пути, где
// строки только вставляли и удаляли. Локально не видно: dev ходит
// суперпользователем. На бою правка падала бы «нет доступа», и номер без пояса
// не ушёл бы НИКОГДА (решение владельца В-85).
'client_sms_campaign_phones' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
// DELETE добавлен в Этапе 4 (строка листа 4.13, журналы В-184, В-186). Кнопка
// «Продолжить рассылку» убирает строки «не хватило денег»: джоб считает обработанным
// любой номер с записью этой рассылки, и без удаления такой номер молча выпал бы.
// Оставить строку рядом с будущим «отправлено» не даёт уникальный ключ по
// (клиент, рассылка, номер). Миграция `2026_08_01_101000`.
'client_sms_messages' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_optouts' => ['SELECT', 'INSERT', 'DELETE'],
'client_sms_contacts' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_templates' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_tariffs' => ['SELECT'],
'client_sms_settings' => ['SELECT'],
'client_sms_senders' => ['SELECT', 'INSERT', 'UPDATE'],
'client_sms_auto_rule' => ['SELECT', 'INSERT', 'UPDATE'],
'sms_global_optouts' => ['SELECT'],
];
/**
* Служебные роли: что модуль обещает АДМИН-зоне и служебному работнику.
*
* Появилось при приёмке Этапа 2 (журнал В-80, замечание проверяющего З-2): сторож
* выше спрашивал базу только про рабочую роль, а гардов с именами двух других ролей
* в миграциях семь. Опечатка внутри такого гарда ошибки НЕ даёт — права молча не
* выдаются, и это ровно тот блокер выката, ради которого сторож и заводился.
*
* Список сверен с миграциями (все `GRANT … TO …` семейства), а не переписан на глаз.
*/
const CLIENT_SMS_SERVICE_GRANTS = [
'crm_admin_user' => [
'client_sms_tariffs' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_settings' => ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
'client_sms_senders' => ['SELECT', 'UPDATE'],
'sms_global_optouts' => ['SELECT', 'INSERT', 'DELETE'],
],
'crm_supplier_worker' => [
'client_sms_senders' => ['SELECT'],
'client_sms_auto_rule' => ['SELECT'],
'sms_global_optouts' => ['SELECT'],
// Добавлено при приёмке Этапа 3 (журнал В-122). Команда добора
// `client-sms:resume-waiting` ходит под этой ролью (`pgsql_supplier`) —
// она кросс-тенантная и tenant-контекст не ставит. Читает рассылки,
// строки снимка и журнал, а строкам снимка ещё и проставляет отправку.
// Прав на эти три таблицы роли выдано НЕ было: локально не видно
// (суперпользователь), а на бою — «нет доступа» каждые 15 минут.
'client_sms_campaigns' => ['SELECT'],
// DELETE добавлен в Этапе 4 (строка листа 4.12, журналы В-152, В-181). Снимок —
// копия персональных данных, и старше 90 дней его уносит команда
// `client-sms:purge-snapshots` под этой же ролью (миграция `2026_08_01_100900`).
// Проверено живым прогоном: без этого права команда каждую ночь ПАДАЕТ с «нет
// доступа к таблице», и персональные данные остаются лежать.
'client_sms_campaign_phones' => ['SELECT', 'UPDATE', 'DELETE'],
// UPDATE добавлен в Этапе 5 (строки листа 5.1–5.2, миграция `2026_08_01_101200`).
// Команда `client-sms:poll-delivery` спрашивает у оператора судьбу сообщений и
// ПРАВИТ существующую строку журнала — новых не пишет намеренно (В-204: новые
// строки делали бы зависшую рассылку «живой» для сторожа, и деньги остались бы
// замороженными). Без этого права на бою команда падает «нет доступа»; локально
// не видно НИКОГДА — dev и тесты ходят суперпользователем (класс В-126/В-127).
'client_sms_messages' => ['SELECT', 'UPDATE'],
],
];
function clientSmsRoleExists(string $role): bool
{
return DB::selectOne('SELECT 1 AS ok FROM pg_roles WHERE rolname = ?', [$role]) !== null;
}
it('у рабочей роли есть обещанные права на таблицы модуля', function () {
if (! clientSmsRoleExists('crm_app_user')) {
$this->markTestSkipped('Роли crm_app_user на этой базе нет — проверять нечего.');
}
foreach (CLIENT_SMS_TABLE_GRANTS as $table => $privileges) {
foreach ($privileges as $privilege) {
$granted = DB::selectOne(
'SELECT has_table_privilege(?, ?, ?) AS ok',
['crm_app_user', $table, $privilege],
)->ok;
expect($granted)->toBeTrue("Роли crm_app_user не выдано право {$privilege} на {$table}");
}
}
});
it('у служебных ролей есть обещанные права на таблицы модуля', function () {
$checked = 0;
foreach (CLIENT_SMS_SERVICE_GRANTS as $role => $tables) {
if (! clientSmsRoleExists($role)) {
continue;
}
foreach ($tables as $table => $privileges) {
foreach ($privileges as $privilege) {
$granted = DB::selectOne(
'SELECT has_table_privilege(?, ?, ?) AS ok',
[$role, $table, $privilege],
)->ok;
expect($granted)->toBeTrue("Роли {$role} не выдано право {$privilege} на {$table}");
$checked++;
}
}
}
if ($checked === 0) {
$this->markTestSkipped('Служебных ролей на этой базе нет — проверять нечего.');
}
});
/**
* Счётчики (sequence) — отдельная история: право на таблицу их НЕ покрывает, и
* именно этого не хватало всем 14 миграциям модуля до фикса В-36.
*
* Спрашиваем про все три роли: миграция прав (`100600`) выдаёт счётчики каждой из них.
*/
it('у ролей есть право пользоваться счётчиками таблиц модуля', function () {
$roles = array_filter(
['crm_app_user', 'crm_admin_user', 'crm_supplier_worker'],
fn (string $role) => clientSmsRoleExists($role),
);
if ($roles === []) {
$this->markTestSkipped('Ролей модуля на этой базе нет — проверять нечего.');
}
$sequences = DB::select(<<<'SQL'
SELECT sequencename FROM pg_sequences
WHERE schemaname = 'public' AND sequencename LIKE 'client\_sms\_%'
SQL);
expect($sequences)->not->toBeEmpty();
foreach ($roles as $role) {
foreach ($sequences as $row) {
$granted = DB::selectOne(
'SELECT has_sequence_privilege(?, ?, ?) AS ok',
[$role, $row->sequencename, 'USAGE'],
)->ok;
expect($granted)->toBeTrue("Роли {$role} не выдано право на счётчик {$row->sequencename}");
}
}
});