Files
portal/app/tests/Feature/Reports/DealsExportProviderTest.php
T
Дмитрий 862243a2b8 fix(reports): защита от CSV/formula-инъекции в писателях-форматтерах (F-CSV-wide)
Ревью раунда 2: F-CSV шире — формула может уйти не только через DealExport, но
и через центральные писатели ОТЧЁТОВ (Managers/Sources/Billing/Deals × csv/xlsx).
Прежний фикс нейтрализовал в DealsExportProvider — неверный слой: он кормит и
JSON-формат, где апостроф портил данные (ReportJobControllerTest).

Перенос защиты в писатели:
  - CsvFormatter — CsvFormulaGuard::neutralizeCell (апостроф, числа не трогаются)
  - XlsxFormatter — опасные строки через setCellValueExplicit TYPE_STRING
    (Excel НЕ вычисляет формулу; XLSX опаснее — считает без предупреждения)
  - DealsExportProvider — откат к сырым данным (JSON больше не портится)
CsvFormulaGuard: + isDangerous()/neutralizeCell() — numeric-aware (ведущий «-»
числа не экранируется).

TDD: unit-тесты CsvFormatter + XlsxFormatter (load xlsx → datatype 's', не 'f').
DealExportController (OpenSpout, отдельный путь Сделок) — без изменений.

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

62 lines
2.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\ReportJob;
use App\Models\Tenant;
use App\Services\Reports\Providers\DealsExportProvider;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->project = Project::factory()->create(['tenant_id' => $this->tenant->id, 'name' => 'Окна Москва']);
});
/** ReportJob без сохранения — провайдер читает только tenant_id + parameters. */
function dealsExportJob(int $tenantId): ReportJob
{
return new ReportJob([
'tenant_id' => $tenantId,
'type' => 'deals_export',
'parameters' => [
'format' => 'csv',
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
],
]);
}
test('headers: 8 колонок', function () {
expect((new DealsExportProvider)->headers())
->toBe(['ID', 'Телефон', 'Контакт', 'Статус', 'Проект', 'Менеджер', 'Стоимость (₽)', 'Получено']);
});
test('отдаёт СЫРЫЕ данные — нейтрализация формул в форматтерах, не в провайдере (F-CSV)', function () {
DB::table('deals')->insert([
'tenant_id' => $this->tenant->id,
'project_id' => $this->project->id,
'phone' => '+79991234567',
'contact_name' => '=HYPERLINK("http://evil")',
'status' => 'new',
'received_at' => Carbon::now()->startOfMonth()->addDays(5),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
DB::table('projects')->where('id', $this->project->id)->update(['name' => '@SUM(1+1)']);
$rows = (new DealsExportProvider)->rows(dealsExportJob($this->tenant->id));
expect($rows)->toHaveCount(1);
// Провайдер НЕ префиксует апострофом — отдаёт как есть (чтобы JSON-формат не
// портился). Нейтрализацию делают CsvFormatter/XlsxFormatter (свои тесты).
expect($rows[0][2])->toBe('=HYPERLINK("http://evil")');
expect($rows[0][1])->toBe('+79991234567');
expect($rows[0][4])->toBe('@SUM(1+1)');
expect($rows[0][0])->toBeInt();
});