2026-05-09 05:33:21 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Jobs\ProcessWebhookJob;
|
2026-05-09 05:49:34 +03:00
|
|
|
|
use App\Models\SystemSetting;
|
2026-05-09 05:33:21 +03:00
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
|
|
use Illuminate\Support\Facades\Bus;
|
2026-05-09 05:49:34 +03:00
|
|
|
|
use Illuminate\Support\Facades\RateLimiter;
|
2026-05-09 05:33:21 +03:00
|
|
|
|
|
|
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
|
$this->tenant = Tenant::factory()->create([
|
|
|
|
|
|
'webhook_token' => 'whk_test_'.bin2hex(random_bytes(8)),
|
|
|
|
|
|
'balance_leads' => 100,
|
|
|
|
|
|
]);
|
2026-05-09 05:49:34 +03:00
|
|
|
|
// Чистим RateLimiter между тестами — иначе lockout из одного теста
|
|
|
|
|
|
// загрязняет следующий.
|
|
|
|
|
|
RateLimiter::clear("webhook:{$this->tenant->id}");
|
2026-05-15 19:16:13 +03:00
|
|
|
|
|
|
|
|
|
|
// Audit-fix B3: дефолт isHmacRequired() изменён на true. Тесты, проверяющие
|
|
|
|
|
|
// НЕ-HMAC аспекты (payload-валидация, rate-limit, CSRF), явно ставят флаг в
|
|
|
|
|
|
// false — иначе запрос без подписи получит 401 ещё до этих проверок.
|
|
|
|
|
|
SystemSetting::firstOrCreate(
|
|
|
|
|
|
['key' => 'webhook_hmac_required'],
|
|
|
|
|
|
['value' => 'false', 'type' => 'bool', 'description' => 'test default', 'updated_at' => now()],
|
|
|
|
|
|
);
|
|
|
|
|
|
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
|
2026-05-09 05:33:21 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('POST /api/webhook/{token} с валидным payload возвращает 202 + dispatch ProcessWebhookJob', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
|
|
$payload = [
|
|
|
|
|
|
'vid' => 12345,
|
|
|
|
|
|
'project' => 'Натяжные потолки',
|
|
|
|
|
|
'phone' => '+7 (999) 123-45-67',
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
'tag' => 'ya_direct',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(202);
|
|
|
|
|
|
expect($r->json('status'))->toBe('accepted');
|
|
|
|
|
|
expect($r->json('tenant_id'))->toBe($this->tenant->id);
|
|
|
|
|
|
|
|
|
|
|
|
Bus::assertDispatched(ProcessWebhookJob::class, function ($job) use ($payload) {
|
|
|
|
|
|
return $job->tenantId === $this->tenant->id
|
|
|
|
|
|
&& $job->data['vid'] === $payload['vid']
|
|
|
|
|
|
&& $job->data['phone'] === $payload['phone'];
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('POST с unknown token → 404', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
$r = $this->postJson('/api/webhook/whk_nonexistent_token_12345', [
|
|
|
|
|
|
'vid' => 1,
|
|
|
|
|
|
'project' => 'X',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
$r->assertStatus(404);
|
|
|
|
|
|
Bus::assertNothingDispatched();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('POST без обязательных полей → 422', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
|
|
|
|
|
// Нет vid/project/phone/time
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(422);
|
|
|
|
|
|
$errors = $r->json('errors');
|
|
|
|
|
|
expect($errors)->toHaveKeys(['vid', 'project', 'phone', 'time']);
|
|
|
|
|
|
Bus::assertNothingDispatched();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('POST с вредной структурой (vid=строка, time=отрицательный) → 422', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
|
|
|
|
|
'vid' => 'не-число',
|
|
|
|
|
|
'project' => 'X',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'time' => -1,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(422);
|
|
|
|
|
|
Bus::assertNothingDispatched();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('POST к webhook НЕ требует CSRF (внешний клиент)', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
// Симулируем запрос БЕЗ X-XSRF-TOKEN — CSRF middleware не должен проверять
|
|
|
|
|
|
// /api/webhook/* (см. bootstrap/app.php validateCsrfTokens except).
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
|
|
|
|
|
'vid' => 1,
|
|
|
|
|
|
'project' => 'X',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
$r->assertStatus(202);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('POST с `phones` array (multi-phone payload) принимается', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
|
|
|
|
|
'vid' => 1,
|
|
|
|
|
|
'project' => 'Окна',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'phones' => ['+7 (999) 000-00-01', '+7 (999) 000-00-02'],
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
$r->assertStatus(202);
|
|
|
|
|
|
Bus::assertDispatched(ProcessWebhookJob::class, function ($job) {
|
|
|
|
|
|
return is_array($job->data['phones']) && count($job->data['phones']) === 2;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-05-09 05:49:34 +03:00
|
|
|
|
|
|
|
|
|
|
test('HMAC: валидная подпись sha256=hex(hmac_sha256(body, token)) проходит', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
$payload = [
|
|
|
|
|
|
'vid' => 1,
|
|
|
|
|
|
'project' => 'X',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
];
|
|
|
|
|
|
$rawBody = json_encode($payload);
|
|
|
|
|
|
$signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token);
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
|
|
|
|
|
'CONTENT_TYPE' => 'application/json',
|
|
|
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
|
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
|
|
|
|
|
|
], $rawBody);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(202);
|
|
|
|
|
|
Bus::assertDispatched(ProcessWebhookJob::class);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('HMAC: невалидная подпись → 401, dispatch НЕ происходит', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
$payload = [
|
|
|
|
|
|
'vid' => 1,
|
|
|
|
|
|
'project' => 'X',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
];
|
|
|
|
|
|
$rawBody = json_encode($payload);
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
|
|
|
|
|
'CONTENT_TYPE' => 'application/json',
|
|
|
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
|
'HTTP_X_WEBHOOK_SIGNATURE' => 'sha256=deadbeef'.str_repeat('0', 56),
|
|
|
|
|
|
], $rawBody);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(401);
|
|
|
|
|
|
expect($r->json('message'))->toContain('HMAC');
|
|
|
|
|
|
Bus::assertNothingDispatched();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-15 19:16:13 +03:00
|
|
|
|
test('HMAC: настройка отсутствует → HMAC обязателен по умолчанию (B3) → 401', function () {
|
2026-05-09 05:49:34 +03:00
|
|
|
|
Bus::fake();
|
2026-05-15 19:16:13 +03:00
|
|
|
|
// Audit-fix B3: code-default isHmacRequired() = true. Удаляем настройку,
|
|
|
|
|
|
// чтобы проверить именно отсутствие ключа в system_settings.
|
|
|
|
|
|
SystemSetting::where('key', 'webhook_hmac_required')->delete();
|
|
|
|
|
|
|
2026-05-09 05:49:34 +03:00
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
|
|
|
|
|
'vid' => 1,
|
|
|
|
|
|
'project' => 'X',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
]);
|
2026-05-15 19:16:13 +03:00
|
|
|
|
$r->assertStatus(401);
|
|
|
|
|
|
Bus::assertNothingDispatched();
|
2026-05-09 05:49:34 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('rate-limit: системный лимит RPS×60 в минуту, 429 + Retry-After на превышении', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
// Устанавливаем низкий лимит через system_settings — иначе тест слишком долгий
|
|
|
|
|
|
// (default 100 RPS = 6000/мин). Подменяем через update.
|
|
|
|
|
|
SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']);
|
|
|
|
|
|
|
|
|
|
|
|
$payload = [
|
|
|
|
|
|
'vid' => 1,
|
|
|
|
|
|
'project' => 'X',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 1 RPS × 60 = 60 запросов/мин. Делаем 60 успешных.
|
|
|
|
|
|
for ($i = 0; $i < 60; $i++) {
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
|
|
|
|
|
$r->assertStatus(202);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 61-й — превышение.
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
|
|
|
|
|
$r->assertStatus(429);
|
|
|
|
|
|
expect($r->json('retry_after'))->toBeInt()->toBeGreaterThan(0);
|
|
|
|
|
|
expect($r->headers->get('Retry-After'))->not->toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 06:43:21 +03:00
|
|
|
|
test('webhook_hmac_required=true: запрос без X-Webhook-Signature → 401', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
SystemSetting::firstOrCreate(
|
|
|
|
|
|
['key' => 'webhook_hmac_required'],
|
|
|
|
|
|
['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
|
|
|
|
|
);
|
|
|
|
|
|
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']);
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
|
|
|
|
|
'vid' => 1,
|
|
|
|
|
|
'project' => 'X',
|
|
|
|
|
|
'phone' => '+7 (999) 000-00-00',
|
|
|
|
|
|
'time' => time(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
$r->assertStatus(401);
|
|
|
|
|
|
expect($r->json('message'))->toContain('требуется');
|
|
|
|
|
|
Bus::assertNothingDispatched();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('webhook_hmac_required=true: с валидной HMAC-подписью → 202', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
SystemSetting::firstOrCreate(
|
|
|
|
|
|
['key' => 'webhook_hmac_required'],
|
|
|
|
|
|
['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
|
|
|
|
|
);
|
|
|
|
|
|
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']);
|
|
|
|
|
|
|
|
|
|
|
|
$payload = [
|
|
|
|
|
|
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
|
|
|
|
|
];
|
|
|
|
|
|
$rawBody = json_encode($payload);
|
|
|
|
|
|
$signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token);
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
|
|
|
|
|
'CONTENT_TYPE' => 'application/json',
|
|
|
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
|
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
|
|
|
|
|
|
], $rawBody);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(202);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('webhook_hmac_required=false: header опционален → 202 без подписи', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
SystemSetting::firstOrCreate(
|
|
|
|
|
|
['key' => 'webhook_hmac_required'],
|
|
|
|
|
|
['value' => 'false', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
|
|
|
|
|
);
|
|
|
|
|
|
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
|
|
|
|
|
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
$r->assertStatus(202);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 05:49:34 +03:00
|
|
|
|
test('rate-limit: ключ изолирован per-token (другой tenant не блокирует)', function () {
|
|
|
|
|
|
Bus::fake();
|
|
|
|
|
|
SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']);
|
|
|
|
|
|
|
|
|
|
|
|
$tenantOther = Tenant::factory()->create([
|
|
|
|
|
|
'webhook_token' => 'whk_other_'.bin2hex(random_bytes(8)),
|
|
|
|
|
|
]);
|
|
|
|
|
|
RateLimiter::clear("webhook:{$tenantOther->id}");
|
|
|
|
|
|
|
|
|
|
|
|
$payload = [
|
|
|
|
|
|
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// Заполняем лимит первого tenant'а
|
|
|
|
|
|
for ($i = 0; $i < 60; $i++) {
|
|
|
|
|
|
$this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(202);
|
|
|
|
|
|
}
|
|
|
|
|
|
$this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(429);
|
|
|
|
|
|
|
|
|
|
|
|
// Второй tenant — без проблем.
|
|
|
|
|
|
$r = $this->postJson("/api/webhook/{$tenantOther->webhook_token}", $payload);
|
|
|
|
|
|
$r->assertStatus(202);
|
|
|
|
|
|
});
|