Files
portal/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
T
Дмитрий f78a85595c fix(http): IP allowlist fail-closed в production env (Plan 2.6 #ii)
Закрывает CV.11 audit WARN #5 (пустой supplier_ip_allowlist '[]' = fail-open
на production — любой IP пропускается).

Изменение: SupplierWebhookController::verifyIpAllowlist — пустой allowlist
возвращает true только если env != production. На production пустой allowlist
блокирует (404). На dev/testing fail-open сохраняется (для localhost development).

TDD: +2 теста (production env empty → 404; testing env empty → 202).
Inline-warning header обновлён.

phpstan-baseline: count: 6 → 8 (postJson() Pest TestCall PhpDoc-quirk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:08:16 +03:00

125 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\SystemSetting;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
uses(DatabaseTransactions::class);
beforeEach(function () {
SystemSetting::query()->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);
});
it('blocks empty IP allowlist в production env (Plan 2.6 fix #ii)', function () {
// beforeEach уже выставил secret valid + allowlist '[]'.
// На production env пустой allowlist должен fail-closed → 404.
app()->detectEnvironment(fn () => 'production');
Bus::fake();
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
]);
$response->assertStatus(404);
});
it('allows empty IP allowlist в testing env (Plan 2.6 fix #ii — fail-open для dev)', function () {
// beforeEach уже выставил allowlist '[]'. Testing env (default) — пропускает.
Bus::fake();
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 999, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
]);
$response->assertStatus(202);
});