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); });