feat(http): SupplierWebhookController — platform-wide /api/webhook/supplier/{secret}
Defense-in-depth: secret (≥32 chars system_setting) + IP allowlist (CIDR). Несовпадение → 404. UNIQUE vid → 200 OK на дубль (idempotency). Тесты пока FAIL (route регистрируется в Task 7 — пишем "красные" тесты заранее для TDD-цикла). Spec §5.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\SupplierLead;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Платформенный endpoint для входящего stream'а лидов от crm.bp-gr.ru.
|
||||
*
|
||||
* URL: POST /api/webhook/supplier/{secret}
|
||||
*
|
||||
* Защита (defense-in-depth, spec §5.1):
|
||||
* 1. {secret} в URL — platform-wide токен (≥32 chars), хранится в
|
||||
* system_settings.supplier_webhook_secret. hash_equals для сравнения.
|
||||
* 2. IP allowlist (system_settings.supplier_ip_allowlist) — JSON array IP/CIDR.
|
||||
* Пустой массив = пропускать всех (DEV mode); на prod заполнить.
|
||||
*
|
||||
* Несовпадение secret OR IP → 404 (не палим существование endpoint'а).
|
||||
*
|
||||
* Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем
|
||||
* 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды).
|
||||
*
|
||||
* Backward-compat: legacy /api/webhook/{token} (per-tenant) живёт параллельно
|
||||
* на WebhookReceiveController — не пересекается.
|
||||
*/
|
||||
class SupplierWebhookController extends Controller
|
||||
{
|
||||
public function receive(Request $request, string $secret): JsonResponse
|
||||
{
|
||||
if (! $this->verifySecret($secret)) {
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
if (! $this->verifyIpAllowlist($request->ip())) {
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'time' => 'required|integer|min:1',
|
||||
'tag' => 'nullable|string|max:255',
|
||||
'phones' => 'nullable|array',
|
||||
'phones.*' => 'string|regex:/^7\d{10}$/',
|
||||
]);
|
||||
|
||||
$existing = SupplierLead::query()->where('vid', $validated['vid'])->first();
|
||||
if ($existing !== null) {
|
||||
return response()->json([
|
||||
'status' => 'already_processed',
|
||||
'supplier_lead_id' => $existing->id,
|
||||
], 200);
|
||||
}
|
||||
|
||||
$platform = $this->parsePlatform($validated['project']);
|
||||
|
||||
$lead = SupplierLead::create([
|
||||
'platform' => $platform,
|
||||
'raw_payload' => $validated,
|
||||
'vid' => $validated['vid'],
|
||||
'phone' => $validated['phone'],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
|
||||
RouteSupplierLeadJob::dispatch($lead->id);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'accepted',
|
||||
'supplier_lead_id' => $lead->id,
|
||||
], 202);
|
||||
}
|
||||
|
||||
private function verifySecret(string $providedSecret): bool
|
||||
{
|
||||
$row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
|
||||
if ($row === null) {
|
||||
return false;
|
||||
}
|
||||
$expected = (string) $row->value;
|
||||
if ($expected === '__SET_ON_DEPLOY__' || strlen($expected) < 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($expected, $providedSecret);
|
||||
}
|
||||
|
||||
private function verifyIpAllowlist(?string $clientIp): bool
|
||||
{
|
||||
if ($clientIp === null) {
|
||||
return false;
|
||||
}
|
||||
$row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first();
|
||||
if ($row === null) {
|
||||
return true;
|
||||
}
|
||||
$list = json_decode((string) $row->value, true) ?: [];
|
||||
if ($list === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return IpUtils::checkIp($clientIp, $list);
|
||||
}
|
||||
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
preg_match('/^(B[123])_/', $project, $m);
|
||||
|
||||
return $m[1] ?? 'B1';
|
||||
}
|
||||
}
|
||||
@@ -594,6 +594,18 @@ parameters:
|
||||
count: 10
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withServerVariables\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?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;
|
||||
|
||||
uses(DatabaseTransactions::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' => '[]']);
|
||||
});
|
||||
|
||||
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'],
|
||||
'time' => 1703781939,
|
||||
]);
|
||||
|
||||
$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);
|
||||
});
|
||||
|
||||
it('rejects invalid project format (no B[123]_ prefix) with 422', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 1, 'project' => 'invalid_format', 'phone' => '79991234567', 'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
Reference in New Issue
Block a user