Files
portal/app/tests/Feature/WebhookReceiveTest.php
T
Дмитрий 1fd6f7f597 fix(security): harden impersonation/webhook/tenant — audit A2/A3/B3/C2
- A2: impersonation _dev_plain_code в ответе init только в local/testing
- A3: X-Tenant-Id принимается только в local/testing (anti-spoof тенанта)
- B3: WebhookReceiveController isHmacRequired() default false→true (fail-secure)
- C2: SupplierWebhookController per-IP rate-limit 600/min (DoS-guard)
- WebhookReceiveTest обновлён под B3 (отсутствие настройки → 401)

Tests: 70/70 passed (323 assertions) — Webhook/Impersonation/Tenant suites.

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

287 lines
11 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}");
// 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']);
});
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: настройка отсутствует → HMAC обязателен по умолчанию (B3) → 401', function () {
Bus::fake();
// Audit-fix B3: code-default isHmacRequired() = true. Удаляем настройку,
// чтобы проверить именно отсутствие ключа в system_settings.
SystemSetting::where('key', 'webhook_hmac_required')->delete();
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
]);
$r->assertStatus(401);
Bus::assertNothingDispatched();
});
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);
});