157 lines
6.9 KiB
PHP
157 lines
6.9 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Тесты сервиса анонимизации ПДн (152-ФЗ право на удаление, ст.21) для сделок:
|
||
|
|
* хирургическая чистка телефона субъекта в скалярной колонке и JSONB phones,
|
||
|
|
* сохранение телефонов со-контактов, no-op при удалении только по email.
|
||
|
|
* Решения: (1) хирургично — матч по скаляру И JSONB, чистим только элемент
|
||
|
|
* субъекта; (2) email-путь для deals — подтверждённый no-op (нет email-поля).
|
||
|
|
* См. спеку F-P1b: docs/superpowers/specs/2026-06-16-pd-erasure-deals-jsonb-surgical.md
|
||
|
|
*/
|
||
|
|
|
||
|
|
use App\Services\Pd\PdErasureService;
|
||
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
|
|
use Illuminate\Support\Facades\DB;
|
||
|
|
|
||
|
|
uses(DatabaseTransactions::class);
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers — данные вставляются через pgsql_supplier (committed), т.к. сервис
|
||
|
|
// читает/пишет тем же BYPASSRLS-соединением. Телефоны уникальны на прогон.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/** Создать stub saas_admin_users через pgsql_supplier и вернуть id. */
|
||
|
|
function pdSvcStubAdmin(): int
|
||
|
|
{
|
||
|
|
return (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
|
||
|
|
'email' => 'pd-svc-stub-'.uniqid().'@system.local',
|
||
|
|
'full_name' => 'PdErasureService Test Stub',
|
||
|
|
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||
|
|
'role' => 'super_admin',
|
||
|
|
'is_active' => false,
|
||
|
|
'sso_provider' => 'local',
|
||
|
|
'is_break_glass' => false,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Создать тенант через pgsql_supplier и вернуть id. */
|
||
|
|
function pdSvcCreateTenant(): int
|
||
|
|
{
|
||
|
|
return (int) DB::connection('pgsql_supplier')->table('tenants')->insertGetId([
|
||
|
|
'subdomain' => 'pd-svc-'.uniqid(),
|
||
|
|
'organization_name' => 'PdErasureService Test Org',
|
||
|
|
'contact_email' => 'pd-svc@test.local',
|
||
|
|
'status' => 'active',
|
||
|
|
'balance_rub' => '0.00',
|
||
|
|
'balance_leads' => 0,
|
||
|
|
'is_trial' => false,
|
||
|
|
'chargeback_unrecovered_rub' => '0.00',
|
||
|
|
'created_at' => now(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests — описания содержат "PdErasureService" для точечного --filter.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
it('PdErasureService surgically removes subject phone from a co-contact deal JSONB without touching scalar owner data (F-P1b)', function (): void {
|
||
|
|
$adminId = pdSvcStubAdmin();
|
||
|
|
$tenantId = pdSvcCreateTenant();
|
||
|
|
|
||
|
|
$victimPhone = '+790'.random_int(1000000, 9999999);
|
||
|
|
$ownerPhone = '+791'.random_int(1000000, 9999999);
|
||
|
|
$coContactPhone = '+792'.random_int(1000000, 9999999);
|
||
|
|
|
||
|
|
// Сделка принадлежит другому человеку (ownerPhone); телефон субъекта —
|
||
|
|
// лишь дополнительный в JSONB phones рядом с телефоном со-контакта.
|
||
|
|
$dealId = (int) DB::connection('pgsql_supplier')->table('deals')->insertGetId([
|
||
|
|
'tenant_id' => $tenantId,
|
||
|
|
'project_id' => 1,
|
||
|
|
'phone' => $ownerPhone,
|
||
|
|
'phones' => json_encode([$victimPhone, $coContactPhone]),
|
||
|
|
'contact_name' => 'Владелец',
|
||
|
|
'received_at' => now(),
|
||
|
|
'created_at' => now(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$counts = app(PdErasureService::class)
|
||
|
|
->eraseSubject(null, $victimPhone, $tenantId, $adminId, null);
|
||
|
|
|
||
|
|
expect($counts['deals'])->toBeGreaterThanOrEqual(1);
|
||
|
|
|
||
|
|
$deal = DB::connection('pgsql_supplier')->table('deals')->where('id', $dealId)->first();
|
||
|
|
|
||
|
|
// Скалярные ПДн владельца сделки НЕ трогаются.
|
||
|
|
expect($deal->phone)->toBe($ownerPhone);
|
||
|
|
expect($deal->contact_name)->toBe('Владелец');
|
||
|
|
|
||
|
|
// Телефон субъекта вычищен из массива, со-контакт сохранён (хирургично).
|
||
|
|
$phones = json_decode($deal->phones ?? '[]', true);
|
||
|
|
expect($phones)->not->toContain($victimPhone);
|
||
|
|
expect($phones)->toContain($coContactPhone);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('PdErasureService preserves co-contact phones when erasing the deal owner (F-P1b)', function (): void {
|
||
|
|
$adminId = pdSvcStubAdmin();
|
||
|
|
$tenantId = pdSvcCreateTenant();
|
||
|
|
|
||
|
|
$victimPhone = '+790'.random_int(1000000, 9999999);
|
||
|
|
$coContactPhone = '+792'.random_int(1000000, 9999999);
|
||
|
|
|
||
|
|
// Субъект — владелец сделки (скалярный phone), плюс его номер и со-контакт
|
||
|
|
// в JSONB phones.
|
||
|
|
$dealId = (int) DB::connection('pgsql_supplier')->table('deals')->insertGetId([
|
||
|
|
'tenant_id' => $tenantId,
|
||
|
|
'project_id' => 1,
|
||
|
|
'phone' => $victimPhone,
|
||
|
|
'phones' => json_encode([$victimPhone, $coContactPhone]),
|
||
|
|
'contact_name' => 'Жертва',
|
||
|
|
'received_at' => now(),
|
||
|
|
'created_at' => now(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$counts = app(PdErasureService::class)
|
||
|
|
->eraseSubject(null, $victimPhone, $tenantId, $adminId, null);
|
||
|
|
|
||
|
|
expect($counts['deals'])->toBeGreaterThanOrEqual(1);
|
||
|
|
|
||
|
|
$deal = DB::connection('pgsql_supplier')->table('deals')->where('id', $dealId)->first();
|
||
|
|
|
||
|
|
// Скалярные ПДн владельца-субъекта анонимизированы.
|
||
|
|
expect($deal->phone)->toBe('+7000XXXXXXX');
|
||
|
|
expect($deal->contact_name)->toBe('Удалено');
|
||
|
|
|
||
|
|
// Телефон субъекта убран из массива, со-контакт сохранён.
|
||
|
|
$phones = json_decode($deal->phones ?? '[]', true);
|
||
|
|
expect($phones)->not->toContain($victimPhone);
|
||
|
|
expect($phones)->toContain($coContactPhone);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('PdErasureService does not touch deals when only email is given — deals have no email (F-P1b no-op)', function (): void {
|
||
|
|
$adminId = pdSvcStubAdmin();
|
||
|
|
$tenantId = pdSvcCreateTenant();
|
||
|
|
|
||
|
|
$dealPhone = '+790'.random_int(1000000, 9999999);
|
||
|
|
$dealId = (int) DB::connection('pgsql_supplier')->table('deals')->insertGetId([
|
||
|
|
'tenant_id' => $tenantId,
|
||
|
|
'project_id' => 1,
|
||
|
|
'phone' => $dealPhone,
|
||
|
|
'phones' => json_encode([$dealPhone]),
|
||
|
|
'contact_name' => 'Нетронутый',
|
||
|
|
'received_at' => now(),
|
||
|
|
'created_at' => now(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Удаление только по email — в deals нет email, сделки не сопоставляются.
|
||
|
|
$counts = app(PdErasureService::class)
|
||
|
|
->eraseSubject('email-only-'.uniqid().'@example.com', null, $tenantId, $adminId, null);
|
||
|
|
|
||
|
|
expect($counts['deals'])->toBe(0);
|
||
|
|
|
||
|
|
$deal = DB::connection('pgsql_supplier')->table('deals')->where('id', $dealId)->first();
|
||
|
|
expect($deal->phone)->toBe($dealPhone);
|
||
|
|
expect($deal->contact_name)->toBe('Нетронутый');
|
||
|
|
});
|