diff --git a/app/app/Http/Controllers/Api/SupplierWebhookController.php b/app/app/Http/Controllers/Api/SupplierWebhookController.php new file mode 100644 index 00000000..69f8ada8 --- /dev/null +++ b/app/app/Http/Controllers/Api/SupplierWebhookController.php @@ -0,0 +1,120 @@ +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'; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 314ee320..12ed4d3e 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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 diff --git a/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php b/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php new file mode 100644 index 00000000..ffcff59f --- /dev/null +++ b/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php @@ -0,0 +1,100 @@ +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); +});