tenant = Tenant::factory()->create([ 'webhook_token' => 'whk_test_'.bin2hex(random_bytes(8)), 'balance_leads' => 100, ]); // Чистим RateLimiter между тестами — иначе lockout из одного теста // загрязняет следующий. RateLimiter::clear("webhook:{$this->tenant->id}"); }); 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; }); }); 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(); }); test('HMAC: отсутствие header → пропускаем (backward-compat) → 202', function () { Bus::fake(); $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('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(); }); 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); }); 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); });