Files
portal/app/tests/Feature/Advertising/AdWalletUnderRealRoleTest.php
T
Дмитрий 0204411e98 fix реклама за показы: из очереди кошелёк молча не возвращал клиенту заморозку
Тот же класс, что нашли во втором проверяющем на СМС: чтение денег идёт вне куска
с контекстом клиента. Проверил свой модуль целиком по этому признаку.

Дыра одна, в самом больном месте. Джоб проверки модерации кампании перечисляет
кампании через обходное соединение, но возврат заморозки зовёт кошелёк на обычном
соединении и без контекста. Защита по клиентам на таблицах рекламы устроена строго:
без контекста сравнение с пустотой, и база отдаёт ноль строк.

Чем кончалось бы на бою: Яндекс отклонил весь набор объявлений - кампания помечена
отклонённой - возврат заморозки видит "кошелька нет", честно выходит - деньги клиента
остаются замороженными навсегда. Ни ошибки, ни строки в журнале, свободный остаток
занижен, новую кампанию запустить не на что.

Живой замер на опытной базе, откатанный сразу:
суперюзером, как ходят тесты - 1
боевой ролью без контекста, как читает джоб - 0
боевой ролью с контекстом - 1

Тесты ходят суперюзером, который защиту обходит, и увидеть это физически не могут.

Починка в самом кошельке, а не у вызывающего. Тенант приходит в кошелёк явным
доводом, значит и контекст - забота кошелька, а не каждого, кто его позовёт. Так
закрыт весь класс сразу, а не один случай.

Сторож гоняет деньги ПОД БОЕВОЙ РОЛЬЮ и без контекста. Написан до починки, падал
двумя разными способами: возврат молча ничего не делал, заморозка бросала "кошелька
нет". Опасен именно молчаливый.

Обход остальных фоновых путей: перечисление кампаний, сторож зависших заданий,
сборка аудитории, постановка задания разведки, запись в ленту - у всех защита на
месте. Список получателей уведомления читается из таблицы, у которой политика мягкая:
без контекста она открыта, ноль строк не даёт.

Реклама 395 из 395 при 1251 проверке, было 393. Админка и кошелёк 25 из 25.
На боевой не выкатывалось, никуда не отправлялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:56:18 +03:00

57 lines
2.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Support\Facades\DB;
/**
* 🔴 КЛАСС «тихий ноль». Тесты ходят СУПЕРЮЗЕРОМ, который обходит защиту по клиентам,
* и потому физически не могут увидеть эту дыру. На бою очередь работает под обычной
* ролью, а защита на таблицах `ad_*` устроена СТРОГО:
*
* USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
*
* Нет контекста — сравнение с NULL, и база молча отдаёт ноль строк. `release()` видит
* «кошелька нет», честно выходит, и заморозка клиента остаётся навсегда. Ни ошибки,
* ни строки в журнале.
*
* Этот сторож гоняет деньги ПОД БОЕВОЙ РОЛЬЮ и БЕЗ контекста — то есть ровно так, как
* их зовёт джоб проверки модерации.
*/
function podBoevoyRolyu(callable $chto): void
{
DB::statement('SET ROLE crm_app_user');
try {
$chto();
} finally {
DB::statement('RESET ROLE');
}
}
it('снимает заморозку под боевой ролью без контекста клиента — как её зовёт джоб модерации', function () {
$tenant = Tenant::factory()->create();
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']);
$svc = app(AdWalletService::class);
$svc->freeze($tenant->id, 'yandex', 'campaign', 1, '1000.00');
// Яндекс отклонил весь набор объявлений: джоб возвращает клиенту заморозку целиком.
podBoevoyRolyu(fn () => $svc->release($tenant->id, 'yandex', 'campaign', 1));
// Читаем суперюзером — прибор не должен зависеть от той же защиты, что проверяем.
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00');
});
it('замораживает под боевой ролью без контекста клиента', function () {
$tenant = Tenant::factory()->create();
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']);
$svc = app(AdWalletService::class);
podBoevoyRolyu(fn () => $svc->freeze($tenant->id, 'yandex', 'campaign', 2, '700.00'));
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('700.00');
});