Files
portal/app/tests/Feature/WebhookSettingsControllerTest.php
T
Дмитрий 6933ddc538 fix(security): SSRF-гард на сохранении webhook target_url (защита будущей доставки)
- update(): WebhookUrlGuard блокирует сохранение private/reserved/loopback IP →
  422 validation error на target_url; небезопасные адреса не попадают в БД,
  любой будущий потребитель (test() + outbound-доставка) читает только безопасные
- NB: будущая outbound-доставка обязана ВДОБАВОК звать guard перед отправкой
  (DNS-rebinding); outbound-pipeline пока не построен (комментарий в update())
- тесты: +PUT private-IP→422 не сохраняет; webhook target_url → публичные
  IP-литералы (убрал DNS-резолюцию example.ru-хостов, webhook-suite 93s→5s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 03:25:16 +03:00

141 lines
5.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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://93.184.216.34/hook',
]);
$response = $this->getJson('/api/tenants/me/webhook-settings');
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
expect($response->json('data'))->not->toHaveKey('secret_hash');
});
test('GET webhook-settings изолирован по тенанту', function () {
$otherTenant = Tenant::factory()->create();
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $otherTenant->id,
'user_id' => $otherUser->id,
'target_url' => 'https://other.example.ru/hook',
]);
$response = $this->getJson('/api/tenants/me/webhook-settings');
$response->assertOk();
expect($response->json('data'))->toBeNull();
});
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
$response = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://93.184.216.34/hook',
]);
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/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://8.8.8.8/hook',
]);
$response = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://1.1.1.1/hook',
]);
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://1.1.1.1/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('PUT webhook-settings: 422 для приватного/служебного IP в target_url (SSRF), не сохраняет', function () {
$this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://169.254.169.254/hook',
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
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://93.184.216.34/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://93.184.216.34/hook');
});
test('POST webhooks/test возвращает ok=false при ошибке endpoint', function () {
Http::fake(['*' => Http::response([], 500)]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://93.184.216.34/hook',
]);
$response = $this->postJson('/api/webhooks/test');
$response->assertOk();
expect($response->json('ok'))->toBeFalse();
expect($response->json('status'))->toBe(500);
});
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);
});