Files
portal/app/tests/Feature/Http/Webhook/WebhookHardeningTest.php
T
Дмитрий a5c278adaf test: рекламная папка и журнал вебхука перестали оставлять записи — остаток 44 → 0
Прогон рекламной папки оставлял в базе 44 записи. Теперь ноль, папка целиком
зелёная — 351 из 351. Полная tests/Feature: 3282 теста, 0 падений, и после неё
в базе не остаётся НИЧЕГО, кроме справочников, которые кладутся при сборке.

Два места прошлая смена считала неизлечимыми — оба вылечились, потому что
диагноз был не перепроверен:

  1. CampaignBannerEndpointsTest оставлял 24 записи, больше половины всей грязи.
     Он нарочно ловит отказ базы, а после отказа внутри транзакции Postgres
     глушит все следующие команды. Лечение — ловить отказ в ОТДЕЛЬНОЙ точке
     сохранения: DB::transaction внутри уже открытой ставит savepoint и
     откатывает только его. Приёмка вырезанием: убрал уникальный ключ в
     миграции — тест покраснел ровно там, где должен; вернул — зелёный.

  2. AdWalletUnderRealRoleTest. В промте: «откат противопоказан, он ходит
     настоящей ролью базы». Оказалось — не противопоказан, смена роли идёт по
     ТОМУ ЖЕ соединению и незакоммиченные записи видны. Тут была реальная
     опасность, что откат обезвредит самого сторожа, поэтому вырезание делал
     отдельно: убрал у службы установку контекста клиента — оба теста
     покраснели. Значит сторож сторожит по-прежнему.

Журнал вебхука оставлял 4 строки: он пишется ВТОРЫМ соединением к базе, куда
обычный откат не дотягивается. Двум файлам добавлена общая связка соединений
(SharesSupplierPdo) — папка вебхука теперь оставляет ноль.

Прежняя оценка исправлена. В промте стояло «~20 файлов оставляют по одной-две
записи». На деле после уборки восьми главных грязь оставляли ДВА файла из всей
папки: 2 и 3 записи, сумма сошлась с наблюдаемыми 5 ровно. «Нет отката» и
«гадит в базу» — разные вещи: папка Autopodbor, где без отката 16 файлов, не
оставляет ни одной записи. Мерить надо остатком, а не поиском по тексту.

Боевой код не тронут: git diff по app/app/ пуст после каждой правки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 01:15:44 +03:00

97 lines
4.0 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 App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\SystemSetting;
use App\Support\WebhookUrlGuard;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Tests\Concerns\SharesSupplierPdo;
// SharesSupplierPdo — не ради видимости, а ради ОТКАТА: журнал вебхука пишется
// вторым соединением (pgsql_supplier), и без общего соединения откат его не
// достаёт — строки журнала оставались в базе после прогона.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
SystemSetting::query()->where('key', 'supplier_webhook_secret')
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
SystemSetting::query()->where('key', 'supplier_ip_allowlist')->update(['value' => '[]']);
});
// --- Часть А: DNS-rebind пиннинг (юнит на WebhookUrlGuard::safeDeliveryIp) ---
test('safeDeliveryIp блокирует приватный/служебный адрес и не отдаёт ip для пиннинга', function () {
foreach ([
'https://10.0.0.1/hook',
'https://169.254.169.254/hook',
'https://127.0.0.1/hook',
'https://192.168.1.1/hook',
] as $url) {
$result = WebhookUrlGuard::safeDeliveryIp($url);
expect($result['blockReason'])->not->toBeNull()
->and($result['ip'])->toBeNull();
}
});
test('safeDeliveryIp пропускает публичный IP и отдаёт его для пиннинга', function () {
$result = WebhookUrlGuard::safeDeliveryIp('https://1.1.1.1/hook');
expect($result['blockReason'])->toBeNull()
->and($result['ip'])->toBe('1.1.1.1');
});
// --- Часть Б: аддитивный HMAC для supplier-webhook ---
test('secretless /api/webhook/supplier принимает валидную HMAC-подпись → 202', function () {
Bus::fake();
$secret = 'test-secret-32chars-aaaaaaaaaaaaaa';
$body = json_encode(['vid' => 55501, 'project' => 'B1_hmac.ru', 'phone' => '79991234567', 'time' => time()]);
$sig = hash_hmac('sha256', $body, $secret);
$response = $this->call('POST', '/api/webhook/supplier', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_WEBHOOK_SIGNATURE' => $sig,
], $body);
expect($response->getStatusCode())->toBe(202);
expect(SupplierLead::where('vid', 55501)->exists())->toBeTrue();
Bus::assertDispatched(RouteSupplierLeadJob::class);
});
test('secretless /api/webhook/supplier без подписи → 404', function () {
$body = json_encode(['vid' => 55502, 'project' => 'B1_hmac.ru', 'phone' => '79991234567', 'time' => time()]);
$response = $this->call('POST', '/api/webhook/supplier', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], $body);
expect($response->getStatusCode())->toBe(404);
});
test('secretless /api/webhook/supplier с неверной подписью → 404', function () {
$body = json_encode(['vid' => 55503, 'project' => 'B1_hmac.ru', 'phone' => '79991234567', 'time' => time()]);
$response = $this->call('POST', '/api/webhook/supplier', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_WEBHOOK_SIGNATURE' => 'deadbeefdeadbeefdeadbeefdeadbeef',
], $body);
expect($response->getStatusCode())->toBe(404);
});
test('существующий {secret}-маршрут продолжает принимать по URL-секрету → 202', function () {
Bus::fake();
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 55504, 'project' => 'B1_hmac.ru', 'phone' => '79991234567', 'time' => time(),
]);
expect($response->getStatusCode())->toBe(202);
});