d1976c9ccf
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>
107 lines
4.6 KiB
PHP
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();
|
|
});
|