2026-05-10 19:38:18 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Jobs\RouteSupplierLeadJob;
|
|
|
|
|
use App\Models\SupplierLead;
|
|
|
|
|
use App\Models\SystemSetting;
|
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
|
use Illuminate\Support\Facades\Bus;
|
2026-07-31 08:52:14 +03:00
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Tests\Concerns\SharesSupplierPdo;
|
2026-05-10 19:38:18 +03:00
|
|
|
|
2026-07-31 08:52:14 +03:00
|
|
|
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
2026-05-10 19:38:18 +03:00
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
SystemSetting::query()->where('key', 'supplier_webhook_secret')->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
|
|
|
|
SystemSetting::query()->where('key', 'supplier_ip_allowlist')->update(['value' => '[]']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 404 for invalid secret', function () {
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/wrong-secret', [
|
|
|
|
|
'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
|
|
|
|
|
]);
|
|
|
|
|
$response->assertStatus(404);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 404 if IP not in allowlist (when allowlist non-empty)', function () {
|
|
|
|
|
SystemSetting::query()->where('key', 'supplier_ip_allowlist')
|
|
|
|
|
->update(['value' => '["1.2.3.4", "10.0.0.0/24"]']);
|
|
|
|
|
|
|
|
|
|
$response = $this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8'])
|
|
|
|
|
->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
|
|
|
|
|
]);
|
|
|
|
|
$response->assertStatus(404);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes IP allowlist when IP matches CIDR', function () {
|
|
|
|
|
SystemSetting::query()->where('key', 'supplier_ip_allowlist')
|
|
|
|
|
->update(['value' => '["10.0.0.0/24"]']);
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
$response = $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.50'])
|
|
|
|
|
->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
|
|
|
|
|
]);
|
|
|
|
|
$response->assertStatus(202);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('inserts supplier_lead row + dispatches RouteSupplierLeadJob', function () {
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 432176649,
|
|
|
|
|
'project' => 'B1_vashinvestor.ru',
|
|
|
|
|
'tag' => 'Ваш инвестор',
|
|
|
|
|
'phone' => '79991234567',
|
|
|
|
|
'phones' => ['79991234567'],
|
2026-05-10 23:11:26 +03:00
|
|
|
'time' => time(),
|
2026-05-10 19:38:18 +03:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(202);
|
|
|
|
|
expect(SupplierLead::where('vid', 432176649)->exists())->toBeTrue();
|
|
|
|
|
Bus::assertDispatched(RouteSupplierLeadJob::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 200 OK on duplicate vid (idempotency)', function () {
|
|
|
|
|
SupplierLead::factory()->create(['vid' => 12345]);
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 12345,
|
|
|
|
|
'project' => 'B1_test.ru',
|
|
|
|
|
'phone' => '79991234567',
|
|
|
|
|
'time' => time(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(200);
|
|
|
|
|
expect(SupplierLead::where('vid', 12345)->count())->toBe(1);
|
|
|
|
|
Bus::assertNotDispatched(RouteSupplierLeadJob::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects invalid payload (missing vid) with 422', function () {
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
|
|
|
|
|
]);
|
|
|
|
|
$response->assertStatus(422)->assertJsonValidationErrors('vid');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects invalid phone format (not 7XXXXXXXXXX) with 422', function () {
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '89991234567', 'time' => time(),
|
|
|
|
|
]);
|
|
|
|
|
$response->assertStatus(422);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-25 08:46:43 +03:00
|
|
|
it('accepts project without B[123]_ prefix as DIRECT (Phase 3)', function () {
|
|
|
|
|
// Фаза 3: требование префикса B1_/B2_/B3_ снято намеренно — проект без префикса
|
|
|
|
|
// принимается (202) и маршрутизируется как platform=DIRECT, чтобы не терять заявки.
|
2026-05-10 19:38:18 +03:00
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 1, 'project' => 'invalid_format', 'phone' => '79991234567', 'time' => time(),
|
|
|
|
|
]);
|
2026-06-25 08:46:43 +03:00
|
|
|
$response->assertStatus(202);
|
2026-05-10 19:38:18 +03:00
|
|
|
});
|
2026-05-10 23:08:16 +03:00
|
|
|
|
|
|
|
|
it('blocks empty IP allowlist в production env (Plan 2.6 fix #ii)', function () {
|
|
|
|
|
// beforeEach уже выставил secret valid + allowlist '[]'.
|
|
|
|
|
// На production env пустой allowlist должен fail-closed → 404.
|
|
|
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(404);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('allows empty IP allowlist в testing env (Plan 2.6 fix #ii — fail-open для dev)', function () {
|
|
|
|
|
// beforeEach уже выставил allowlist '[]'. Testing env (default) — пропускает.
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 999, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(202);
|
|
|
|
|
});
|
2026-05-10 23:11:26 +03:00
|
|
|
|
|
|
|
|
it('rejects timestamp older than 24h (Plan 2.6 fix #iii — partition guard)', function () {
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 100001,
|
|
|
|
|
'project' => 'B1_old-time.ru',
|
|
|
|
|
'phone' => '79991234567',
|
|
|
|
|
'time' => now()->subDays(2)->getTimestamp(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(422)->assertJsonValidationErrors('time');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects timestamp more than 24h in future (Plan 2.6 fix #iii — partition guard)', function () {
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 100002,
|
|
|
|
|
'project' => 'B1_future-time.ru',
|
|
|
|
|
'phone' => '79991234567',
|
|
|
|
|
'time' => now()->addDays(2)->getTimestamp(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(422)->assertJsonValidationErrors('time');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts timestamp within ±24h window (Plan 2.6 fix #iii — partition guard)', function () {
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
|
|
|
|
'vid' => 100003,
|
|
|
|
|
'project' => 'B1_valid-time.ru',
|
|
|
|
|
'phone' => '79991234567',
|
|
|
|
|
'time' => now()->subHours(6)->getTimestamp(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(202);
|
|
|
|
|
});
|
2026-07-31 08:52:14 +03:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Журнал вебхука поставщика (31.07.2026). Прежний logSupplierWebhook() писал в
|
|
|
|
|
// таблицу webhook_log, снесённую ещё 24.05 вместе с legacy-каналом, и молча
|
|
|
|
|
// выходил по Schema::hasTable → отказы были невидимы. Итог: 70 отказов 404 за
|
|
|
|
|
// 10 дней (поставщик долбился со СТАРЫМ паролем на старый адрес) нашлись только
|
|
|
|
|
// в логе nginx, случайно. Отпечаток пароля (первые 8 символов md5) отвечает на
|
|
|
|
|
// главный вопрос разбора «это чужой ключ или наш» и сам секретом не является.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('logs a rejected call with the secret fingerprint so a stale endpoint is visible', function () {
|
|
|
|
|
$stale = 'stale-secret-32chars-bbbbbbbbbbbbbb';
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/webhook/supplier/{$stale}", [
|
|
|
|
|
'vid' => 77001, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
|
|
|
|
|
])->assertStatus(404);
|
|
|
|
|
|
|
|
|
|
$row = DB::connection('pgsql_supplier')->table('supplier_webhook_log')->latest('id')->first();
|
|
|
|
|
expect($row)->not->toBeNull();
|
|
|
|
|
expect($row->status)->toBe('rejected_secret');
|
|
|
|
|
expect($row->secret_fingerprint)->toBe(substr(md5($stale), 0, 8));
|
|
|
|
|
expect($row->supplier_lead_id)->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('logs an accepted call with the created lead id', function () {
|
|
|
|
|
Bus::fake();
|
|
|
|
|
$secret = 'test-secret-32chars-aaaaaaaaaaaaaa';
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/webhook/supplier/{$secret}", [
|
|
|
|
|
'vid' => 77002, 'project' => 'B1_test.ru', 'phone' => '79991234568', 'time' => time(),
|
|
|
|
|
])->assertStatus(202);
|
|
|
|
|
|
|
|
|
|
$row = DB::connection('pgsql_supplier')->table('supplier_webhook_log')->latest('id')->first();
|
|
|
|
|
expect($row->status)->toBe('received');
|
|
|
|
|
expect($row->secret_fingerprint)->toBe(substr(md5($secret), 0, 8));
|
|
|
|
|
expect((int) $row->supplier_lead_id)->toBe((int) SupplierLead::where('vid', 77002)->value('id'));
|
|
|
|
|
});
|