Files
portal/app/tests/Feature/ClientSms/MigrationGrantsTest.php
T
Дмитрий 6e29acb7af feat(смс-клиент): снимок получателей живёт 90 дней и чистится сам
Строка листа 4.12, решение владельца В-45. Снимок получателей — копия
ПЕРСОНАЛЬНЫХ данных: список номеров рассылки с пометками, кому уйдёт и кому
нет. Делается один раз при создании рассылки и больше не пересматривается
(В-39), то есть после отправки лежит мёртвым грузом. Хранить его вечно нельзя.

Что сделано:
· команда `client-sms:purge-snapshots` в расписании раз в сутки в 03:30
  (проверено schedule:list) уносит строки снимка старше 90 дней. Ходит
  служебным соединением — она кросс-клиентская и пометку клиента не ставит;
· пачками по 5 000: снимок бывает на 20 000 строк, одним DELETE по дате это
  долгая блокировка. Прибор стоит именно на цикл — 5 001 строка обязана
  уйти целиком, иначе одна строка ПДн осталась бы жить вечно;
· рассылка и её итоги НЕ трогаются: `client_sms_campaigns` и журнал
  сообщений остаются, как велел владелец. Цена этого названа вслух (В-178):
  через 90 дней уже нельзя ответить, кто из получателей ждал своего утра;
· срок живёт в коде, в админке не правится (В-177): срок зависания рассылки —
  рабочая настройка, а 90 дней — обязательство про персональные данные, одно
  для всего портала. При ручном запуске срок передать можно, нулевой
  отклоняется человеческими словами — он снёс бы снимки живых рассылок;
· про снимок НЕзакончившейся рассылки команда говорит вслух и в журнал
  сервера (В-176): в норме такого не бывает, и молчать об этом нельзя.

Право `DELETE` служебной роли — миграция 2026_08_01_100900, схема v9.21
(В-152). Номер и версию взял по каталогу: названные планом были заняты
Task 5 — третий раз этот класс (В-175). Сторож `MigrationGrantsTest`
расширен и спрашивает саму базу, а не текст миграции.

🔴 Живой прогон под боевой ролью crm_supplier_worker УТОЧНИЛ мину В-152
(В-181). На стенде разрешающих политик srv_bypass нет вовсе (В-179), поэтому
бой воспроизведён: политика поставлена тем же текстом, что в
db/03_service_bypass_policies.sql, и после прогона убрана. Тройка:
(1) политика есть, права нет — команда УПАЛА «нет доступа к таблице», строки
целы (план предсказывал «удалит ноль и отрапортует успехом» — в жизни
падение); (2) право есть, политики нет — «Удалено строк снимка: 0» с кодом
УСПЕХА, вот настоящий тихий ноль; (3) обе опоры — удалено 3 из 4, свежая
строка на месте, рассылка и её сообщение на месте, предупреждение о
незаконченной рассылке прозвучало. Вывод для выката: без права беда ВИДНА
(падение), без политики НЕ видна (успешный ноль).

Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов
17/17, phpstan 2 чужие давние, pint чисто. Фронт не трогался вовсе — vitest
и vue-tsc не гонялись, правок в экранах нет ни одной. Вырезов восемь, каждый
покраснел ровно там, где вырезан; девятый — подмена служебного соединения на
обычное — НЕ покраснел вообще (6/6 зелёных), и это честный результат: чем
ходит команда, ловится только живым прогоном. Стенд возвращён и сверен со
снимком ДО; намеренное изменение одно — миграция накатана на dev-базу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 07:47:02 +03:00

168 lines
9.2 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'],
// DELETE добавлен в Этапе 4 (строка листа 4.12, журналы В-152, В-181). Снимок —
// копия персональных данных, и старше 90 дней его уносит команда
// `client-sms:purge-snapshots` под этой же ролью (миграция `2026_08_01_100900`).
// Проверено живым прогоном: без этого права команда каждую ночь ПАДАЕТ с «нет
// доступа к таблице», и персональные данные остаются лежать.
'client_sms_campaign_phones' => ['SELECT', 'UPDATE', 'DELETE'],
'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}");
}
}
});