From 326690934632fac64bb701ab3f5ab48b59bb1604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 15 May 2026 22:13:32 +0300 Subject: [PATCH] feat(api): outbound webhook settings endpoints (closes J5 part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit J5/D4/D5: the outbound_webhook_subscriptions table existed in schema but had zero code. Adds the OutboundWebhookSubscription model + factory and WebhookSettingsController with GET/PUT /api/tenants/me/webhook-settings (one subscription per tenant; secret generated + returned once on creation, bcrypt-hashed) and POST /api/webhooks/test (unsigned connectivity check — HMAC-signed event delivery is a separate post-MVP epic). Tenant-scoped via auth:sanctum + tenant middleware. phpstan-baseline.neon: additive-only entries for new test file (Pest\PendingCalls\TestCall false-positives — documented project pattern) and OutboundWebhookSubscriptionFactory method.childReturnType (same pattern as ProjectFactory/TenantFactory/UserFactory already in baseline). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/WebhookSettingsController.php | 137 ++++++++++++++++++ .../Models/OutboundWebhookSubscription.php | 72 +++++++++ .../OutboundWebhookSubscriptionFactory.php | 37 +++++ app/phpstan-baseline.neon | 48 ++++++ app/routes/web.php | 7 + .../Feature/WebhookSettingsControllerTest.php | 102 +++++++++++++ 6 files changed, 403 insertions(+) create mode 100644 app/app/Http/Controllers/Api/WebhookSettingsController.php create mode 100644 app/app/Models/OutboundWebhookSubscription.php create mode 100644 app/database/factories/OutboundWebhookSubscriptionFactory.php create mode 100644 app/tests/Feature/WebhookSettingsControllerTest.php diff --git a/app/app/Http/Controllers/Api/WebhookSettingsController.php b/app/app/Http/Controllers/Api/WebhookSettingsController.php new file mode 100644 index 00000000..3cbba832 --- /dev/null +++ b/app/app/Http/Controllers/Api/WebhookSettingsController.php @@ -0,0 +1,137 @@ + События по умолчанию для новой подписки. */ + private const DEFAULT_EVENTS = ['deal.created', 'deal.status_changed']; + + public function show(Request $request): JsonResponse + { + $sub = $this->currentSubscription($request); + + if ($sub === null) { + return response()->json(['data' => null]); + } + + return response()->json(['data' => [ + 'target_url' => $sub->target_url, + 'secret_prefix' => $sub->secret_prefix, + 'events' => $sub->events, + 'is_active' => $sub->is_active, + ]]); + } + + public function update(Request $request): JsonResponse + { + $validated = $request->validate([ + 'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'], + ]); + + $sub = $this->currentSubscription($request); + $plainSecret = null; + + if ($sub === null) { + $plainSecret = self::SECRET_PREFIX.Str::random(40); + $sub = OutboundWebhookSubscription::query()->create([ + 'tenant_id' => (int) $request->user()->tenant_id, + 'user_id' => (int) $request->user()->id, + 'name' => 'Webhook', + 'target_url' => $validated['target_url'], + 'secret_hash' => Hash::make($plainSecret), + 'secret_prefix' => substr($plainSecret, 0, 10), + 'events' => self::DEFAULT_EVENTS, + 'is_active' => true, + ]); + } else { + $sub->update(['target_url' => $validated['target_url']]); + } + + $payload = [ + 'target_url' => $sub->target_url, + 'secret_prefix' => $sub->secret_prefix, + 'events' => $sub->events, + 'is_active' => $sub->is_active, + ]; + if ($plainSecret !== null) { + $payload['secret'] = $plainSecret; + } + + return response()->json(['data' => $payload]); + } + + public function test(Request $request): JsonResponse + { + $sub = $this->currentSubscription($request); + + if ($sub === null) { + return response()->json([ + 'message' => 'Сначала сохраните URL webhook.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $testPayload = [ + 'event' => 'webhook.test', + 'sent_at' => now()->toIso8601String(), + 'message' => 'Тестовая доставка webhook от Лидерра.', + ]; + + // MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных + // IP) — пост-MVP security-review; URL уже ограничен https:// валидацией. + try { + $response = Http::timeout(10) + ->withHeaders(['X-Webhook-Event' => 'webhook.test']) + ->post($sub->target_url, $testPayload); + + return response()->json([ + 'ok' => $response->successful(), + 'status' => $response->status(), + 'message' => $response->successful() + ? "Тестовый запрос доставлен (HTTP {$response->status()})." + : "Endpoint ответил HTTP {$response->status()}.", + ]); + } catch (\Throwable $e) { + return response()->json([ + 'ok' => false, + 'status' => null, + 'message' => 'Не удалось доставить тестовый запрос: '.$e->getMessage(), + ]); + } + } + + private function currentSubscription(Request $request): ?OutboundWebhookSubscription + { + $tenantId = (int) $request->user()->tenant_id; + + // Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS. + return OutboundWebhookSubscription::query() + ->where('tenant_id', $tenantId) + ->orderByDesc('id') + ->first(); + } +} diff --git a/app/app/Models/OutboundWebhookSubscription.php b/app/app/Models/OutboundWebhookSubscription.php new file mode 100644 index 00000000..7cff9105 --- /dev/null +++ b/app/app/Models/OutboundWebhookSubscription.php @@ -0,0 +1,72 @@ + */ + use HasFactory; + + protected $fillable = [ + 'tenant_id', + 'user_id', + 'name', + 'target_url', + 'secret_hash', + 'secret_prefix', + 'events', + 'custom_headers', + 'is_active', + 'paused_at', + ]; + + protected $hidden = ['secret_hash']; + + protected function casts(): array + { + return [ + 'events' => 'array', + 'custom_headers' => 'array', + 'is_active' => 'boolean', + 'consecutive_failures' => 'integer', + 'paused_at' => 'datetime', + 'last_delivery_at' => 'datetime', + 'last_failure_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/database/factories/OutboundWebhookSubscriptionFactory.php b/app/database/factories/OutboundWebhookSubscriptionFactory.php new file mode 100644 index 00000000..5dda5e0a --- /dev/null +++ b/app/database/factories/OutboundWebhookSubscriptionFactory.php @@ -0,0 +1,37 @@ + + */ +class OutboundWebhookSubscriptionFactory extends Factory +{ + protected $model = OutboundWebhookSubscription::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'user_id' => User::factory(), + 'name' => 'Webhook', + 'target_url' => 'https://'.fake()->domainName().'/webhook', + 'secret_hash' => Hash::make('whsec_'.Str::random(40)), + 'secret_prefix' => 'whsec_'.Str::lower(Str::random(4)), + 'events' => ['deal.created', 'deal.status_changed'], + 'is_active' => true, + ]; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index bacbacb1..148311b6 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -102,6 +102,12 @@ parameters: count: 1 path: app/Services/Project/ProjectService.php + - + message: '#^Return type \(array\\) of method Database\\Factories\\OutboundWebhookSubscriptionFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/OutboundWebhookSubscriptionFactory.php + - message: '#^Return type \(array\\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -1170,6 +1176,48 @@ parameters: count: 14 path: tests/Feature/WebhookReceiveTest.php + - + message: '#^Access to an undefined property Pest\\Mixins\\Expectation\\:\:\$not\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/WebhookSettingsControllerTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 6 + path: tests/Feature/WebhookSettingsControllerTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 4 + path: tests/Feature/WebhookSettingsControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/WebhookSettingsControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/WebhookSettingsControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/WebhookSettingsControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:putJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/WebhookSettingsControllerTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$resolver\.$#' identifier: property.notFound diff --git a/app/routes/web.php b/app/routes/web.php index a8100336..5bf72bf7 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -131,6 +131,13 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(fu Route::post('/regenerate', 'App\Http\Controllers\Api\ApiKeyController@regenerate'); }); +// Настройки исходящего webhook'а тенанта (audit D4/D5/J5). +Route::middleware(['auth:sanctum', 'tenant'])->group(function () { + Route::get('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@show'); + Route::put('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@update'); + Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test'); +}); + // Сделки — manual create через UI (NewDealDialog). На prod: middleware // 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром. // diff --git a/app/tests/Feature/WebhookSettingsControllerTest.php b/app/tests/Feature/WebhookSettingsControllerTest.php new file mode 100644 index 00000000..ec8c6163 --- /dev/null +++ b/app/tests/Feature/WebhookSettingsControllerTest.php @@ -0,0 +1,102 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +test('GET webhook-settings: null когда подписки нет', function () { + $response = $this->getJson('/api/tenants/me/webhook-settings'); + $response->assertOk(); + expect($response->json('data'))->toBeNull(); +}); + +test('GET webhook-settings возвращает подписку тенанта', function () { + OutboundWebhookSubscription::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'user_id' => $this->user->id, + 'target_url' => 'https://crm.example.ru/hook', + ]); + + $response = $this->getJson('/api/tenants/me/webhook-settings'); + + $response->assertOk(); + expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook'); + expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']); + expect($response->json('data'))->not->toHaveKey('secret_hash'); +}); + +test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () { + $response = $this->putJson('/api/tenants/me/webhook-settings', [ + 'target_url' => 'https://crm.example.ru/hook', + ]); + + $response->assertOk(); + expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook'); + expect($response->json('data.secret'))->toStartWith('whsec_'); + expect($response->json('data.events'))->toBeArray()->not->toBeEmpty(); + + $row = OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->first(); + expect($row)->not->toBeNull(); + expect(Hash::check($response->json('data.secret'), $row->secret_hash))->toBeTrue(); +}); + +test('PUT webhook-settings обновляет URL существующей подписки без нового secret', function () { + OutboundWebhookSubscription::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'user_id' => $this->user->id, + 'target_url' => 'https://old.example.ru/hook', + ]); + + $response = $this->putJson('/api/tenants/me/webhook-settings', [ + 'target_url' => 'https://new.example.ru/hook', + ]); + + $response->assertOk(); + expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook'); + expect($response->json('data'))->not->toHaveKey('secret'); + expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1); +}); + +test('PUT webhook-settings: 422 при не-https URL', function () { + $this->putJson('/api/tenants/me/webhook-settings', [ + 'target_url' => 'http://insecure.example.ru/hook', + ])->assertStatus(422)->assertJsonValidationErrorFor('target_url'); +}); + +test('POST webhooks/test отправляет запрос и возвращает результат', function () { + Http::fake(['*' => Http::response(['ok' => true], 200)]); + OutboundWebhookSubscription::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'user_id' => $this->user->id, + 'target_url' => 'https://crm.example.ru/hook', + ]); + + $response = $this->postJson('/api/webhooks/test'); + + $response->assertOk(); + expect($response->json('ok'))->toBeTrue(); + expect($response->json('status'))->toBe(200); + Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook'); +}); + +test('POST webhooks/test: 422 когда подписки нет', function () { + $this->postJson('/api/webhooks/test')->assertStatus(422); +}); + +test('GET webhook-settings без auth: 401', function () { + auth()->logout(); + $this->getJson('/api/tenants/me/webhook-settings')->assertStatus(401); +});