Files
portal/app/tests/Feature/ClientSms/MigrationGrantsTest.php
T
Дмитрий d242819e27 fix(смс-клиент): три блокера выката — снимок читался без пометки клиента, правка снимка и базы была без прав
Продолжение приёмки Этапа 3 по указанию владельца: «проверь замечание — так это или нет —
и посмотри всё окружение на этот класс ошибок». Замечание оказалось верным, и рядом с ним
нашлось ещё два блокера того же семейства. Ни один из трёх не виден ни одному тесту.

🔴 В-125. Джоб отправки читал снимок получателей ВНЕ пометки клиента: строки 115 и 139
стояли голыми в handle(), а обёртка открывалась только внутри цикла. Чтение снимка ленивое —
запрос уходит не тогда, когда читателя позвали, а когда забирают очередную пачку, то есть
уже за пределами чужой транзакции, а SET LOCAL живёт только до её конца.

Замер у самой базы на ОДНОЙ строке снимка: суперпользователь (так идут все наши тесты) —
1 строка, боевая роль crm_app_user без пометки — 0, она же с пометкой — 1. Без ошибки,
молча. На бою: рассылка закрывается «готово, отправлено 0»; а если в снимке есть ждущие
своего утра — статус «ждёт утра», заморозка НЕ снимается, команда добора будит рассылку
каждые 15 минут, та снова читает ноль, и деньги висят замороженными без конца.

Починка стоит в самом читателе, а не у зовущего: он оборачивает КАЖДЫЙ свой запрос сам.
Полагаться на внимательность каждого, кто его позовёт, оказалось нельзя — ровно на этом
дыра и выросла. Зовущим не мешает: под запросом человека пометка уже стоит, вложенная
транзакция ставит то же значение.

🔴 В-126 и В-127. Этап 3 научил двух помощников ПРАВИТЬ снимок и клиентскую базу —
проставлять найденный у ДаДаты регион, пояс и оператора. А обе таблицы заводились под путь
«строки только вставляют и удаляют»: прав на правку им не выдавали. Замер: UPDATE под
боевой ролью — «нет доступа к таблице», SELECT той же ролью работает.

На бою: номер, у которого пояс не был известен сразу, не получил бы его НИКОГДА, а номер
без пояса не отправляется вовсе (решение владельца В-85). Для канала «своя база» пояс не
известен ни у одного номера, пока помощник его не проставит, — то есть этот канал не
отправил бы ни одного сообщения.

Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись
схемы v9.16. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, и
обещание опять оказалось неполным — как в В-122 неделей раньше.

⚠️ В-128, не чиню, называю. В трёх докблоках записано, будто служебная роль обходит изоляцию.
ПИЛОТ.md от 07.07: на боевом кластере её не обходит НИ ОДНА роль. Значит служебное соединение
живо не обходом, а политиками srv_bypass, и перезапуск db/03_service_bypass_policies.sql при
выкате — не подстраховка, а несущая опора. Правка текстов — уровень всего приложения, не этапа.

Доказательства.
· Тест В-125 проверяет не результат (его подделать нельзя — суперпользователь всё видит), а
  ПОРЯДОК: каждый запрос к снимку обязан идти внутри той же открытой транзакции, где уже
  выставлена пометка. Уровень вложенности отличает «пометка здесь и сейчас» от «стояла раньше,
  в другой, уже закрытой». Красный до починки показал 5 чтений, все без пометки.
· Живой прогон НАСТОЯЩЕГО джоба под боевой ролью (SET ROLE crm_app_user), парно:
  с починкой — «отправлено 2 из 2, статус готово»; без починки — «отправлено 0 из 2, статус
  готово, джоб не упал». Тот самый молчаливый сбой, вживую.
· Права: живой UPDATE под боевой ролью — до миграции «нет доступа» по обеим таблицам, после
  миграции обе правки проходят. Данные пробы откатаны.
· Вырезами трижды: убрал обёртку у чтения снимка — тест краснеет; опечатка в имени роли ВНУТРИ
  гарда (та самая, что ошибки не даёт и права молча не выдаёт) — сторож краснеет; выдал права
  только одной таблице из двух — краснеет на второй. Всё возвращено.

Обход всего окружения на этот же класс: 35 фоновых помощников и 36 команд классифицированы по
тому, ставят ли они пометку клиента и каким соединением ходят. Те, что работают без пометки,
трогают только таблицы БЕЗ изоляции (портал продаж, бот, внешние балансы). Отдельно искал именно
ловушку «ленивое чтение уезжает из чужой обёртки» — в рекламном модуле и сборщике аудитории всё
внутри. Права на автономера у всех новых таблиц выданы обеим ролям, по кошельку перекосов нет.
Не проверял вглубь маршруты портала (их закрывает общая прослойка) и модули вне рекламы/СМС.

Прогоны: СМС 241/241 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676 зелёных +
3 пропущенных (не трогал), phpstan 2 чужие давние, pint чисто. Стенд возвращён: dev-база — те же
12 клиентов и нули по модулю, пробные строки в тестовой базе откатаны.

🔴 При выкате ветки порядок прежний и обязателен: миграции → db/03_service_bypass_policies.sql →
контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Права и изоляция — разные
механизмы, эта миграция того шага не заменяет.
2026-07-29 08:34:02 +03:00

163 lines
8.5 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'],
'client_sms_messages' => ['SELECT', 'INSERT', 'UPDATE'],
'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'],
'client_sms_campaign_phones' => ['SELECT', 'UPDATE'],
'client_sms_messages' => ['SELECT'],
],
];
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}");
}
}
});