Files
portal/app/tests/Feature/Pd/ScrubSoftDeletedDealsCommandTest.php
T
Дмитрий d1976c9ccf feat(pdn): ретеншен ПДн удалённых лидов — анонимизация soft-deleted сделок — F-P1
152-ФЗ блокер B1/F-P1: телефоны и имена контактов soft-deleted сделок не
вычищались и хранились бессрочно. Добавлена плановая команда-ретеншен.

Команда pd:scrub-soft-deleted-deals анонимизирует phone/contact_name/phones
сделок с deleted_at старше N дней; N из system_settings
pd_scrub_soft_deleted_deals_days, по умолчанию no-op — юр.срок не зашит в код.
Значения затирания идентичны PdErasureService. Cross-tenant через
pgsql_supplier BYPASSRLS, идемпотентно, summary-запись в pd_processing_log
системным актором. Планировщик ежедневно 03:30 МСК с heartbeat.

Схема v8.41: partial index deals_deleted_at_index ON deals deleted_at WHERE
deleted_at IS NOT NULL для дешёвой выборки; счётчик индексов 120 на 121.

F-T2 проверен: /api/admin за middleware saas-admin fail-closed 503 — кодовой
правки не требует.

TDD: 4 Pest ScrubSoftDeletedDealsCommandTest GREEN. Escape-per-write — печать
церемонии не опечатывала план.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:24:48 +03:00

107 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SystemSetting;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// Команда читает/пишет через pgsql_supplier (BYPASSRLS). SharesSupplierPdo делит
// PDO, чтобы это соединение видело незакоммиченные данные транзакции теста.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* F-P1: команда-ретеншен анонимизирует ПДн soft-deleted сделок по истечении
* настраиваемого срока. Контракт — спека 2026-06-17-fp1-deal-pii-retention-spec.
*/
function makeRetentionDeal(
Tenant $tenant,
Project $project,
?Carbon\Carbon $deletedAt,
string $phone = '+79991112233',
): Deal {
return Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'phone' => $phone,
'phones' => ['+79995556677'],
'contact_name' => 'Иван',
'received_at' => now(),
'deleted_at' => $deletedAt,
]);
}
/** Сырое чтение сделки без soft-delete scope (PDO делится через SharesSupplierPdo). */
function rawDeal(int $id): ?object
{
return DB::connection('pgsql_supplier')->table('deals')->where('id', $id)->first();
}
it('анонимизирует ПДн soft-deleted сделок старше окна, не трогая свежие и живые', function () {
SystemSetting::create(['key' => 'pd_scrub_soft_deleted_deals_days', 'value' => '30', 'type' => 'int']);
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
$old = makeRetentionDeal($tenant, $project, now()->subDays(40)); // старше окна → чистим
$fresh = makeRetentionDeal($tenant, $project, now()->subDays(5)); // в окне → не трогаем
$live = makeRetentionDeal($tenant, $project, null); // живой → не трогаем
$this->artisan('pd:scrub-soft-deleted-deals')->assertExitCode(0);
$oldRow = rawDeal($old->id);
expect($oldRow->phone)->toBe('+7000XXXXXXX');
expect($oldRow->contact_name)->toBe('Удалено');
expect($oldRow->phones)->toBeNull();
expect(rawDeal($fresh->id)->phone)->toBe('+79991112233');
expect(rawDeal($live->id)->phone)->toBe('+79991112233');
});
it('no-op если retention не настроен (ключ отсутствует)', function () {
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
$old = makeRetentionDeal($tenant, $project, now()->subDays(40));
$this->artisan('pd:scrub-soft-deleted-deals')->assertExitCode(0);
expect(rawDeal($old->id)->phone)->toBe('+79991112233');
});
it('идемпотентна: повторный прогон ничего не меняет', function () {
SystemSetting::create(['key' => 'pd_scrub_soft_deleted_deals_days', 'value' => '30', 'type' => 'int']);
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
$old = makeRetentionDeal($tenant, $project, now()->subDays(40));
$this->artisan('pd:scrub-soft-deleted-deals')->assertExitCode(0);
$this->artisan('pd:scrub-soft-deleted-deals')->assertExitCode(0);
expect(rawDeal($old->id)->phone)->toBe('+7000XXXXXXX');
});
it('пишет запись в pd_processing_log при анонимизации (системный актор)', function () {
SystemSetting::create(['key' => 'pd_scrub_soft_deleted_deals_days', 'value' => '30', 'type' => 'int']);
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeRetentionDeal($tenant, $project, now()->subDays(40));
$this->artisan('pd:scrub-soft-deleted-deals')->assertExitCode(0);
$log = DB::connection('pgsql_supplier')->table('pd_processing_log')
->where('action', 'deleted')
->where('purpose', '152-FZ retention scrub')
->first();
expect($log)->not->toBeNull();
expect($log->actor_admin_user_id)->toBeNull();
expect($log->actor_tenant_user_id)->toBeNull();
});