tenant = Tenant::factory()->create(); $this->user = User::factory()->for($this->tenant)->create(); $this->actingAs($this->user); }); // --- unit: WebhookUrlGuard (IP-литералы, без DNS) --- test('WebhookUrlGuard блокирует приватные/зарезервированные/loopback IP', function (string $url) { expect(WebhookUrlGuard::blockReason($url))->not->toBeNull(); })->with([ 'https://127.0.0.1/hook', // loopback 'https://10.0.0.1/hook', // private A 'https://172.16.0.1/hook', // private B 'https://192.168.1.1/hook', // private C 'https://169.254.169.254/hook', // link-local / cloud metadata 'https://[::1]/hook', // IPv6 loopback ]); test('WebhookUrlGuard пропускает публичный IP', function () { expect(WebhookUrlGuard::blockReason('https://93.184.216.34/hook'))->toBeNull(); }); test('WebhookUrlGuard отклоняет битый URL', function () { expect(WebhookUrlGuard::blockReason('not-a-url'))->not->toBeNull(); }); // --- endpoint: webhooks/test не должен бить во внутреннюю сеть --- test('POST webhooks/test блокирует приватный IP target_url (SSRF) и не шлёт запрос', function () { Http::fake(); OutboundWebhookSubscription::factory()->create([ 'tenant_id' => $this->tenant->id, 'user_id' => $this->user->id, 'target_url' => 'https://169.254.169.254/hook', ]); $this->postJson('/api/webhooks/test')->assertStatus(422); Http::assertNothingSent(); }); test('POST webhooks/test пропускает публичный target_url', 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', ]); $this->postJson('/api/webhooks/test') ->assertOk() ->assertJsonPath('ok', true); Http::assertSentCount(1); });