Files
portal/app/tests/Feature/ClientTg/InputLimitsTest.php
T
Дмитрий b3a86e69ad feat(телеграм-реклама): Этап 1 — безопасность и защита входа модуля «по своей базе»
Закрыты 5 находок аудита (безопасность и валидация входа, деньги не задеты):

- #11 ПДн-скрины робота: полноэкранный скриншот кабинета МТС (мог содержать
  телефоны базы, 152-ФЗ) больше не снимается и не уходит письмом по умолчанию —
  только по явному TG_DEBUG_SHOTS. Чистый хелпер src/shots.js.
- #12 стоп-лист opt-out теперь нормализуется при сравнении (8XXXX / 10-значные
  формы вычищаются), сравнение по голому 7XXXXXXXXXX.
- #13 верхние пределы входа: phones max:200000, phones.* max:32, audience_days
  max:365; store возвращает dropped_count (сколько номеров не распозналось).
- #14 идемпотентность запуска: Фаза A джоба читает кампанию с lockForUpdate —
  два воркера сериализуются на блокировке строки (защита от гонки).
- #2 предстартовый гейт аудитории: в боевом режиме launch НЕ бронирует деньги
  под кампанию с <367 / пустой аудиторией (иначе бронь залипала бы). Порог —
  config client_tg.auto_batch_threshold (367). В песочнице гейта нет.

TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера).
Приёмка: Node 28/28, Pest ClientTg 105/105, phpstan 0, pint чисто, deptrac 0.
Обновлён CampaignApiTest (тест «денег не хватает → 409» засеян ≥367 контактами,
чтобы дойти до проверки денег после нового гейта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 06:37:25 +03:00

89 lines
4.8 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\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Верхние пределы на вход в CampaignController::store (находка #13, задача 1.3).
* `phones`/`audience_days` без верхнего предела → клиент может прислать миллион
* номеров (нагрузка/DoS). `ad_text`/`ad_link` НЕ трогаем — уже ограничены.
*
* dropped_count — число нераспознанных (мусорных) номеров, отброшенных
* storePhones молча; добавочный ключ в ответе store, не ломает плоский контракт.
*
* Auth/tenant — как в CampaignApiTest. Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** @return array<string, mixed> валидный payload создания кампании по списку номеров. */
function limStorePayload(array $overrides = []): array
{
return array_merge([
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'phones' => ['79990000001', '79990000002'],
], $overrides);
}
// 🪤 Полный HTTP-прогон с 200001 реальным элементом здесь НЕ используется:
// замерено — 'phones' => 'array|max:200000' САМ ПО СЕБЕ отбрасывает 200001
// элементов за ~0.1с (Validator::fails() проверялся изолированно), но
// соседнее правило 'phones.*' => 'string|max:32' — это Laravel wildcard-rule,
// который валидирует КАЖДЫЙ элемент независимо от того, что 'phones' уже не
// прошёл (bail/stopOnFirstFailure на это не влияют — разные top-level поля).
// Замер роста: n=1000→0.09с, n=5000→0.37с, n=20000→2.8с (не линейно, ~n^1.8) —
// на n=200001 это уже единицы минут за ОДИН прогон теста, что не пригодно для
// регулярного TDD-цикла. Поэтому граница 200000 проверяется СТРУКТУРНО (текст
// правила в контроллере), а не полным раннтайм-прогоном 200001 элементов.
it('верхний предел phones задан в контроллере как max:200000 (структурная проверка — полный HTTP-прогон 200001 элемента через Laravel Validator занимает единицы минут из-за wildcard-правила phones.*, см. комментарий выше)', function () {
$source = file_get_contents(app_path('Http/Controllers/Api/ClientTg/CampaignController.php'));
expect($source)->toContain("'phones' => 'nullable|array|max:200000',");
});
it('phones немного за пределами разумного (500 валидных номеров) — 201, не ложное срабатывание', function () {
Queue::fake();
$this->postJson('/api/telegram/campaigns', limStorePayload([
'phones' => array_fill(0, 500, '79990000001'),
]))->assertCreated();
});
it('phones.* элемент длиннее 32 символов отклоняется — 422', function () {
$this->postJson('/api/telegram/campaigns', limStorePayload([
'phones' => [str_repeat('7', 40)],
]))->assertStatus(422)->assertJsonValidationErrors('phones.0');
});
it('audience_days=366 при audience_kind=deals отклоняется — 422', function () {
$this->postJson('/api/telegram/campaigns', limStorePayload([
'audience_kind' => 'deals',
'audience_days' => 366,
'phones' => null,
]))->assertStatus(422)->assertJsonValidationErrors('audience_days');
});
it('валидный запрос (2 номера) проходит — 201 и dropped_count=0', function () {
$this->postJson('/api/telegram/campaigns', limStorePayload())
->assertCreated()
->assertJsonPath('dropped_count', 0);
});
it('валидный запрос с 1 мусорным номером — 201 и dropped_count=1', function () {
$this->postJson('/api/telegram/campaigns', limStorePayload([
'phones' => ['79990000001', 'абв'],
]))->assertCreated()->assertJsonPath('dropped_count', 1);
});