Files
portal/app/tests/Feature/WebhookReceiveTest.php
T
Дмитрий 83bb9de2bb phase2(backend-completion): POST /api/deals + webhook_hmac_required + POST /api/deals/export
3 backend-completion после tightening v1.56.

(1) POST /api/deals — manual create endpoint:
- DealController::store. Project firstOrCreate (type='manual'). Deal с
  source_crm_id=NULL. RLS-обёрнутая транзакция.
- Manual НЕ списывает баланс / НЕ дедуп / НЕ SupplierLeadCost.
  ActivityLog с context.source=manual.
- NewDealDialog получил optional tenantId prop. С tenantId — POST → backend-id;
  на error fallback на local-id + warning + dialog open.
- DealsView/KanbanView передают auth.user?.tenant_id.
- Pest +8.

(2) webhook_hmac_required flag в system_settings:
- Seed-row в db/schema.sql (default false backward-compat).
- WebhookReceiveController::isHmacRequired private helper.
- При true: запрос без X-Webhook-Signature → 401.
- Pest +3.

(3) POST /api/deals/export — backend CSV:
- DealController::export. Валидация ids[1-10000]. RLS-обёрнутый whereIn.
- Excel-friendly CSV: BOM "\u{FEFF}" PHP-литерал, ; разделитель, \r\n.
- text/csv attachment headers.
- Frontend applyBulkExport: backend → fallback на client-side
  (buildLocalCsv вынесен).
- Pest +4.

Vitest +3 (всего 245/245).
PHPStan убрал лишнюю Deal->id===null проверку (Eloquent int).
DealsView/KanbanView spec'ы получили setActivePinia.

Регресс: lint+type-check+format ; vitest 245/245 за 17.07 сек (+3);
vite build 1.04 сек; Pint+PHPStan passed; Pest 156/156 за 20.27 сек
(+15 от 141, 675 assertions). Реестр v1.56→v1.57, CLAUDE.md v1.47→v1.48.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:43:21 +03:00

273 lines
9.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\ProcessWebhookJob;
use App\Models\SystemSetting;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\RateLimiter;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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);
});