3266909346
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) <noreply@anthropic.com>
138 lines
5.1 KiB
PHP
138 lines
5.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\OutboundWebhookSubscription;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Str;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
/**
|
||
* Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
|
||
* Endpoints под auth:sanctum + tenant.
|
||
*
|
||
* Одна подписка-ряд на тенанта. Секрет генерируется при создании и
|
||
* показывается ОДИН раз (в БД — bcrypt secret_hash + secret_prefix).
|
||
*
|
||
* test(): MVP делает unsigned connectivity-проверку (реальный POST на
|
||
* target_url, отчёт по HTTP-статусу). HMAC-подписанная доставка событий —
|
||
* отдельный пост-MVP эпик (outbound-pipeline пока не построен).
|
||
*/
|
||
class WebhookSettingsController extends Controller
|
||
{
|
||
private const SECRET_PREFIX = 'whsec_';
|
||
|
||
/** @var list<string> События по умолчанию для новой подписки. */
|
||
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();
|
||
}
|
||
}
|