Files
portal/app/tests/Feature/DashboardSummaryTest.php
T
Дмитрий 2a34ee880a fix(security): закрыть открытые эндпоинты + SSRF-гард webhook перед go-live
- /api/dashboard/summary, /api/managers, /api/lead-statuses: были без auth
  (tenant_id параметром) → auth:sanctum (+tenant); tenant_id из authed-user,
  не из параметра — закрывает кросс-tenant утечку KPI/списка пользователей
- ManagerController: явный where(tenant_id) поверх RLS (BYPASSRLS-роли/тесты)
- WebhookUrlGuard + webhooks/test: SSRF-блок private/reserved/loopback IP
  (cloud-metadata 169.254.169.254 и пр.); update()/delivery — follow-up
- TDD: +EndpointAuthHardeningTest(5) +WebhookSsrfGuardTest(10); обновлены
  Dashboard/Lookups/LeadStatuses тесты под auth
- регрессия tests/Feature 960/964 (2 фейла pre-existing: Vite-manifest env +
  RouteSupplierLeadJobBilling idempotency — оба фейлят и на чистом base)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:15:05 +03:00

150 lines
6.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
/**
* Вспомогательная функция: создать сделку с заданными параметрами.
*
* Фабрика Deal::factory() по умолчанию: received_at = now() (текущий месяц,
* партиция deals_2026_05 существует). is_test = false, deleted_at = null.
* Для тестовых дат subDays(1..6) — всё в мае 2026, партиция есть.
*/
function makeDashboardDeal(
Tenant $tenant,
Project $project,
string $status,
Carbon|CarbonImmutable $receivedAt,
?Carbon $deletedAt = null,
bool $isTest = false,
): Deal {
return Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'status' => $status,
'received_at' => $receivedAt,
'deleted_at' => $deletedAt,
'is_test' => $isTest,
]);
}
/** Авторизоваться как пользователь данного тенанта (auth:sanctum + tenant). */
function actingForTenant(Tenant $tenant): void
{
test()->actingAs(User::factory()->for($tenant)->create());
}
it('401 без авторизации', function () {
$this->getJson('/api/dashboard/summary')->assertStatus(401);
});
it('возвращает структуру summary с range по умолчанию 7d', function () {
$tenant = Tenant::factory()->create([
'limits' => ['max_projects' => 10],
'balance_rub' => '14250.00',
'balance_leads' => 285,
]);
actingForTenant($tenant);
$this->getJson('/api/dashboard/summary')
->assertOk()
->assertJsonPath('range', '7d')
->assertJsonPath('balance.amount_rub', '14250.00')
->assertJsonStructure([
'range',
'leads_received' => ['value', 'delta_pct', 'delta_dir'],
'conversion' => ['value', 'delta_pp', 'delta_dir'],
'active_projects' => ['active', 'limit'],
'balance' => ['amount_rub', 'runway_days', 'runway_leads'],
'activity' => ['points', 'labels', 'max'],
'funnel',
]);
});
it('leads_received считает только сделки окна, без deleted и is_test', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(2));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(3));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
$this->getJson('/api/dashboard/summary?range=7d')
->assertOk()
->assertJsonPath('leads_received.value', 3);
});
it('conversion = доля статуса won в окне', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
$this->getJson('/api/dashboard/summary')
->assertOk()
->assertJsonPath('conversion.value', 25);
});
it('active_projects считает is_active=true + limit из limits', function () {
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
actingForTenant($tenant);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
$this->getJson('/api/dashboard/summary')
->assertOk()
->assertJsonPath('active_projects.active', 2)
->assertJsonPath('active_projects.limit', 10);
});
it('funnel группирует живые сделки по статусу', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
$this->getJson('/api/dashboard/summary')
->assertOk()
->assertJsonPath('funnel.new', 2)
->assertJsonPath('funnel.won', 1);
});
it('activity возвращает 7 точек и 7 меток', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
$this->getJson('/api/dashboard/summary')
->assertOk()
->assertJsonCount(7, 'activity.points')
->assertJsonCount(7, 'activity.labels');
});
it('runway_days использует фикс. 7д-окно независимо от range', function () {
// balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70.
// Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно).
$tenant = Tenant::factory()->create(['balance_leads' => 70]);
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
for ($i = 0; $i <= 6; $i++) {
makeDashboardDeal($tenant, $project, 'new', now()->subDays($i));
}
$this->getJson('/api/dashboard/summary?range=today')
->assertOk()
->assertJsonPath('balance.runway_days', 70);
});