a5c278adaf
Прогон рекламной папки оставлял в базе 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>
97 lines
4.0 KiB
PHP
97 lines
4.0 KiB
PHP
<?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);
|
||
});
|