329 lines
12 KiB
PHP
329 lines
12 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
use App\Models\Tenant;
|
|||
|
|
use App\Models\User;
|
|||
|
|
use App\Services\Pd\PdErasureService;
|
|||
|
|
use Carbon\CarbonImmutable;
|
|||
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|||
|
|
use Illuminate\Support\Facades\DB;
|
|||
|
|
|
|||
|
|
uses(DatabaseTransactions::class);
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Helpers
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
/** Создать stub saas_admin_users и вернуть его id. */
|
|||
|
|
function pdStubAdminUser(string $email = 'pd-test-stub@system.local'): int
|
|||
|
|
{
|
|||
|
|
$existing = DB::table('saas_admin_users')->where('email', $email)->value('id');
|
|||
|
|
if ($existing !== null) {
|
|||
|
|
return (int) $existing;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (int) DB::table('saas_admin_users')->insertGetId([
|
|||
|
|
'email' => $email,
|
|||
|
|
'full_name' => 'PD Test Stub',
|
|||
|
|
'password_hash' => '$2y$04$system-stub-not-loginable',
|
|||
|
|
'role' => 'super_admin',
|
|||
|
|
'is_active' => false,
|
|||
|
|
'sso_provider' => 'local',
|
|||
|
|
'is_break_glass' => false,
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Создать тенант и вернуть его. */
|
|||
|
|
function pdCreateTenant(): Tenant
|
|||
|
|
{
|
|||
|
|
return Tenant::factory()->create([
|
|||
|
|
'subdomain' => 'pd-test-'.uniqid(),
|
|||
|
|
'organization_name' => 'PD Test Org',
|
|||
|
|
'contact_email' => 'pd-tenant@test.local',
|
|||
|
|
'status' => 'active',
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Вставить запись pd_subject_requests напрямую и вернуть id. */
|
|||
|
|
function pdInsertRequest(array $attrs = []): int
|
|||
|
|
{
|
|||
|
|
$defaults = [
|
|||
|
|
'received_at' => now(),
|
|||
|
|
'subject_email' => 'subject@example.com',
|
|||
|
|
'subject_phone' => null,
|
|||
|
|
'subject_full_name' => 'Test Subject',
|
|||
|
|
'request_type' => 'deletion',
|
|||
|
|
'description' => 'Test description',
|
|||
|
|
'status' => 'received',
|
|||
|
|
'tenant_id' => null,
|
|||
|
|
'processing_restricted' => false,
|
|||
|
|
// deadline_at заполняется триггером, но NOT NULL — вставим вручную
|
|||
|
|
'deadline_at' => now()->addDays(30),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return (int) DB::connection('pgsql_supplier')
|
|||
|
|
->table('pd_subject_requests')
|
|||
|
|
->insertGetId(array_merge($defaults, $attrs));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Tests
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
it('index returns paginated list of pd_subject_requests', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
// Вставим 2 записи
|
|||
|
|
pdInsertRequest(['request_type' => 'deletion']);
|
|||
|
|
pdInsertRequest(['request_type' => 'access', 'status' => 'completed']);
|
|||
|
|
|
|||
|
|
$response = $this->getJson('/api/admin/pd-subject-requests');
|
|||
|
|
|
|||
|
|
$response->assertOk();
|
|||
|
|
expect($response->json('total'))->toBeGreaterThanOrEqual(2);
|
|||
|
|
expect($response->json('data'))->toBeArray();
|
|||
|
|
$first = $response->json('data.0');
|
|||
|
|
expect($first)->toHaveKeys(['id', 'received_at', 'request_type', 'status', 'deadline_at']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('index filters by status', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
pdInsertRequest(['status' => 'received']);
|
|||
|
|
pdInsertRequest(['status' => 'completed', 'request_type' => 'access']);
|
|||
|
|
|
|||
|
|
$response = $this->getJson('/api/admin/pd-subject-requests?status=received');
|
|||
|
|
|
|||
|
|
$response->assertOk();
|
|||
|
|
foreach ($response->json('data') as $row) {
|
|||
|
|
expect($row['status'])->toBe('received');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('store creates pd_subject_request with deadline_at ~+30 days from received_at', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
$response = $this->postJson('/api/admin/pd-subject-requests', [
|
|||
|
|
'subject_email' => 'newsubject@example.com',
|
|||
|
|
'request_type' => 'deletion',
|
|||
|
|
'description' => 'Please delete my data.',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$response->assertCreated();
|
|||
|
|
$data = $response->json('data');
|
|||
|
|
expect($data['subject_email'])->toBe('newsubject@example.com');
|
|||
|
|
expect($data['request_type'])->toBe('deletion');
|
|||
|
|
expect($data['status'])->toBe('received');
|
|||
|
|
|
|||
|
|
// deadline_at должен быть ~30 дней вперёд (с погрешностью ±2 дня на тест-лаги)
|
|||
|
|
$deadline = CarbonImmutable::parse($data['deadline_at']);
|
|||
|
|
$received = CarbonImmutable::parse($data['received_at']);
|
|||
|
|
// diffInDays: абсолютное значение (порядок параметров не важен с abs)
|
|||
|
|
$diff = abs($deadline->diffInDays($received));
|
|||
|
|
expect($diff)->toBeGreaterThanOrEqual(29)->toBeLessThanOrEqual(31);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('store validates: at least email or phone required', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
$this->postJson('/api/admin/pd-subject-requests', [
|
|||
|
|
'request_type' => 'deletion',
|
|||
|
|
])->assertStatus(422);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('store validates: request_type must be valid', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
$this->postJson('/api/admin/pd-subject-requests', [
|
|||
|
|
'subject_email' => 'x@y.com',
|
|||
|
|
'request_type' => 'invalid_type',
|
|||
|
|
])->assertStatus(422);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('executeErasure anonymises user email first_name phone and writes pd_processing_log', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
// Используем pgsql_supplier для всех вставок, чтобы FK-проверки работали
|
|||
|
|
// в рамках одного соединения (DatabaseTransactions оборачивает default pgsql,
|
|||
|
|
// но pgsql_supplier видит только committed данные default-соединения).
|
|||
|
|
$stubEmail = 'pd-user-stub-'.uniqid().'@system.local';
|
|||
|
|
$adminId = (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
|
|||
|
|
'email' => $stubEmail,
|
|||
|
|
'full_name' => 'User 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 (тот же физ. сервер/БД)
|
|||
|
|
$tenantId = (int) DB::connection('pgsql_supplier')->table('tenants')->insertGetId([
|
|||
|
|
'subdomain' => 'pd-user-test-'.uniqid(),
|
|||
|
|
'organization_name' => 'PD User Test',
|
|||
|
|
'contact_email' => 'pd-u@test.local',
|
|||
|
|
'status' => 'active',
|
|||
|
|
'balance_rub' => '0.00',
|
|||
|
|
'balance_leads' => 0,
|
|||
|
|
'is_trial' => false,
|
|||
|
|
'chargeback_unrecovered_rub' => '0.00',
|
|||
|
|
'created_at' => now(),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Создаём user с email/phone субъекта
|
|||
|
|
$victimEmail = 'victim-'.uniqid().'@example.com';
|
|||
|
|
$victimPhone = '+79991234567';
|
|||
|
|
|
|||
|
|
$userId = (int) DB::connection('pgsql_supplier')->table('users')->insertGetId([
|
|||
|
|
'tenant_id' => $tenantId,
|
|||
|
|
'email' => $victimEmail,
|
|||
|
|
'password_hash' => '$2y$04$test',
|
|||
|
|
'first_name' => 'Иван',
|
|||
|
|
'last_name' => 'Иванов',
|
|||
|
|
'phone' => $victimPhone,
|
|||
|
|
'is_active' => true,
|
|||
|
|
'created_at' => now(),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$requestId = pdInsertRequest([
|
|||
|
|
'subject_email' => $victimEmail,
|
|||
|
|
'subject_phone' => $victimPhone,
|
|||
|
|
'tenant_id' => $tenantId,
|
|||
|
|
'request_type' => 'deletion',
|
|||
|
|
'status' => 'received',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$response = $this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
|||
|
|
'admin_user_id' => $adminId,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$response->assertOk();
|
|||
|
|
expect($response->json('counts.users'))->toBe(1);
|
|||
|
|
|
|||
|
|
// Проверяем анонимизацию user
|
|||
|
|
$user = DB::connection('pgsql_supplier')->table('users')->where('id', $userId)->first();
|
|||
|
|
expect($user->email)->toContain('erased-');
|
|||
|
|
expect($user->first_name)->toBe('Удалено');
|
|||
|
|
expect($user->phone)->toContain('+7000');
|
|||
|
|
|
|||
|
|
// pd_processing_log должен содержать запись
|
|||
|
|
$log = DB::connection('pgsql_supplier')
|
|||
|
|
->table('pd_processing_log')
|
|||
|
|
->where('subject_id', $userId)
|
|||
|
|
->where('subject_type', 'user')
|
|||
|
|
->where('action', 'deleted')
|
|||
|
|
->where('actor_admin_user_id', $adminId)
|
|||
|
|
->first();
|
|||
|
|
expect($log)->not->toBeNull();
|
|||
|
|
expect($log->purpose)->toBe('152-FZ erasure');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('executeErasure anonymises supplier_lead phone and raw_payload', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
// Создаём stub admin через pgsql_supplier, чтобы FK pd_processing_log работал
|
|||
|
|
// независимо от DatabaseTransactions-транзакции default-соединения.
|
|||
|
|
$stubEmail = 'pd-lead-stub-'.uniqid().'@system.local';
|
|||
|
|
$adminId = (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
|
|||
|
|
'email' => $stubEmail,
|
|||
|
|
'full_name' => 'Lead Test Stub',
|
|||
|
|
'password_hash' => '$2y$04$system-stub-not-loginable',
|
|||
|
|
'role' => 'super_admin',
|
|||
|
|
'is_active' => false,
|
|||
|
|
'sso_provider' => 'local',
|
|||
|
|
'is_break_glass' => false,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$victimPhone = '+79887654321';
|
|||
|
|
|
|||
|
|
$leadId = (int) DB::connection('pgsql_supplier')->table('supplier_leads')->insertGetId([
|
|||
|
|
'platform' => 'B1',
|
|||
|
|
'raw_payload' => json_encode(['phone' => $victimPhone, 'name' => 'Жертва']),
|
|||
|
|
'phone' => $victimPhone,
|
|||
|
|
'received_at' => now(),
|
|||
|
|
'source' => 'webhook',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$requestId = pdInsertRequest([
|
|||
|
|
'subject_phone' => $victimPhone,
|
|||
|
|
'request_type' => 'deletion',
|
|||
|
|
'status' => 'received',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$response = $this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
|||
|
|
'admin_user_id' => $adminId,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$response->assertOk();
|
|||
|
|
expect($response->json('counts.leads'))->toBeGreaterThanOrEqual(1);
|
|||
|
|
|
|||
|
|
$lead = DB::connection('pgsql_supplier')->table('supplier_leads')->where('id', $leadId)->first();
|
|||
|
|
expect($lead->phone)->toBe('+7000XXXXXXX');
|
|||
|
|
$payload = json_decode($lead->raw_payload, true);
|
|||
|
|
expect($payload['erased'])->toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('executeErasure marks pd_subject_request as completed', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
$adminId = pdStubAdminUser();
|
|||
|
|
$requestId = pdInsertRequest([
|
|||
|
|
'subject_email' => 'mark-completed-'.uniqid().'@example.com',
|
|||
|
|
'request_type' => 'deletion',
|
|||
|
|
'status' => 'received',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
|||
|
|
'admin_user_id' => $adminId,
|
|||
|
|
])->assertOk();
|
|||
|
|
|
|||
|
|
$row = DB::connection('pgsql_supplier')
|
|||
|
|
->table('pd_subject_requests')
|
|||
|
|
->where('id', $requestId)
|
|||
|
|
->first();
|
|||
|
|
|
|||
|
|
expect($row->status)->toBe('completed');
|
|||
|
|
expect($row->completed_at)->not->toBeNull();
|
|||
|
|
expect($row->response_text)->toContain('users=');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('executeErasure rejects non-deletion request_type with 422', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
$requestId = pdInsertRequest([
|
|||
|
|
'subject_email' => 'access-request@example.com',
|
|||
|
|
'request_type' => 'access',
|
|||
|
|
'status' => 'received',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase")
|
|||
|
|
->assertStatus(422);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('executeErasure rejects already completed request with 422', function (): void {
|
|||
|
|
$this->actingAs(User::factory()->create());
|
|||
|
|
|
|||
|
|
$requestId = pdInsertRequest([
|
|||
|
|
'subject_email' => 'already-done-'.uniqid().'@example.com',
|
|||
|
|
'request_type' => 'deletion',
|
|||
|
|
'status' => 'completed',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase")
|
|||
|
|
->assertStatus(422);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('saas-admin middleware allows request in testing env', function (): void {
|
|||
|
|
// EnsureSaasAdmin в testing-окружении пропускает всех без проверки.
|
|||
|
|
$response = $this->getJson('/api/admin/pd-subject-requests');
|
|||
|
|
$response->assertOk();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('PdErasureService throws InvalidArgumentException when both email and phone are null', function (): void {
|
|||
|
|
$service = app(PdErasureService::class);
|
|||
|
|
|
|||
|
|
expect(fn () => $service->eraseSubject(null, null, null, 1, null))
|
|||
|
|
->toThrow(InvalidArgumentException::class);
|
|||
|
|
});
|