From 77e98afaa636c8c55c405c619fc4da2dd59221b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 23 May 2026 12:21:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(pd):=20152-=D0=A4=D0=97=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=20=D0=BD=D0=B0=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=E2=80=94=20=D0=BC=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D1=83=D0=BC=20(hole=20#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает дыру #4 аудита журналирования. Объём по выбору заказчика — МИНИМУМ: ✅ Админ-API + кнопка в админке для удаления ПДн субъекта ✅ Сервис анонимизации (users + supplier_leads + deals + webhook_log) ✅ Журнал факта удаления в pd_processing_log ❌ БЕЗ формы самообслуживания на стороне субъекта ❌ БЕЗ email-подтверждения ❌ БЕЗ 30-дневного SLA (trigger deadline_at уже в схеме) Что добавлено: * Eloquent-модель `App\Models\PdSubjectRequest` (таблица уже была в схеме) * Сервис `App\Services\Pd\PdErasureService::eraseSubject()`: - cross-tenant через pgsql_supplier (BYPASSRLS) - транзакционно (rollback при ошибке) - users: email→erased-{id}@deleted.local, first_name→Удалено, last_name→null, phone→+7000{id} - supplier_leads: phone→+7000XXXXXXX, raw_payload→{erased:true} - deals: phone→+7000XXXXXXX, contact_name→Удалено (только если есть phone) - webhook_log: batched UPDATE по 500, raw_payload→{erased,erased_at} - pd_processing_log запись action=deleted за каждого user/lead с actor_admin_user_id (hash-chain audit_chain_hash триггером сам подписывает) - При requestId — pd_subject_requests SET status=completed, completed_at, response_text счёт * Контроллер `AdminPdSubjectRequestsController`: index/show/store/executeErasure * Маршруты под middleware(saas-admin): GET/POST /api/admin/pd-subject-requests, GET /{id}, POST /{id}/erase * Vue: `AdminPdSubjectRequestsView` (Quiet Luxury, таблица + диалог создания + кнопка Анонимизировать для request_type=deletion); ESLint требует v-slot:[`item.X`]= вместо #item.X для динамических slot-имён с точкой * Пункт меню в AdminLayout.vue + route /admin/pd-subject-requests NB: реальная схема — users.first_name/last_name/phone/email; supplier_leads имеет только phone (нет contact_*); deals имеет phone+contact_name (нет contact_email); webhook_log JSONB. PdErasureService адаптирован под факт. Тесты: 12/12 passed (63 assertions, ~2.6s) — index pagination, store + deadline trigger (+30 дней), eraseSubject анонимизация user/lead/deal/log, pd_processing_log запись, request status→completed, отклонение не-deletion типов, gate saas-admin, InvalidArgumentException. Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#4). --- .../Api/AdminPdSubjectRequestsController.php | 222 ++++++++ app/app/Models/PdSubjectRequest.php | 81 +++ app/app/Services/Pd/PdErasureService.php | 257 +++++++++ app/resources/js/api/admin.ts | 65 +++ app/resources/js/layouts/AdminLayout.vue | 1 + app/resources/js/router/index.ts | 12 + .../admin/AdminPdSubjectRequestsView.vue | 498 ++++++++++++++++++ app/routes/web.php | 10 + .../AdminPdSubjectRequestsControllerTest.php | 329 ++++++++++++ 9 files changed, 1475 insertions(+) create mode 100644 app/app/Http/Controllers/Api/AdminPdSubjectRequestsController.php create mode 100644 app/app/Models/PdSubjectRequest.php create mode 100644 app/app/Services/Pd/PdErasureService.php create mode 100644 app/resources/js/views/admin/AdminPdSubjectRequestsView.vue create mode 100644 app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php diff --git a/app/app/Http/Controllers/Api/AdminPdSubjectRequestsController.php b/app/app/Http/Controllers/Api/AdminPdSubjectRequestsController.php new file mode 100644 index 00000000..12e23b97 --- /dev/null +++ b/app/app/Http/Controllers/Api/AdminPdSubjectRequestsController.php @@ -0,0 +1,222 @@ +query('status', ''); + $requestType = (string) $request->query('request_type', ''); + $limit = max(1, min(200, (int) $request->query('limit', '50'))); + $offset = max(0, (int) $request->query('offset', '0')); + + $query = DB::connection('pgsql_supplier') + ->table('pd_subject_requests') + ->orderByDesc('received_at') + ->orderByDesc('id'); + + if ($status !== '') { + $query->where('status', $status); + } + if ($requestType !== '') { + $query->where('request_type', $requestType); + } + + $total = (clone $query)->count('id'); + $rows = $query->limit($limit)->offset($offset)->get(); + + return response()->json([ + 'data' => $rows->map(fn ($r) => $this->formatRow($r)), + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]); + } + + /** + * GET /api/admin/pd-subject-requests/{id} + */ + public function show(int $id): JsonResponse + { + $row = DB::connection('pgsql_supplier') + ->table('pd_subject_requests') + ->where('id', $id) + ->first(); + + if ($row === null) { + return response()->json(['message' => 'Обращение не найдено.'], 404); + } + + return response()->json(['data' => $this->formatRow($row)]); + } + + /** + * POST /api/admin/pd-subject-requests + * + * Создать новое обращение субъекта. Deadline автоматически +30 дней + * через PostgreSQL-триггер trg_pd_subject_requests_deadline. + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'subject_email' => ['nullable', 'email', 'max:255'], + 'subject_phone' => ['nullable', 'string', 'max:20'], + 'subject_full_name' => ['nullable', 'string', 'max:255'], + 'request_type' => ['required', Rule::in(['access', 'rectification', 'deletion', 'objection'])], + 'description' => ['nullable', 'string', 'max:4096'], + 'tenant_id' => ['nullable', 'integer', 'min:1'], + ]); + + // Минимум один идентификатор субъекта + if (empty($validated['subject_email']) && empty($validated['subject_phone'])) { + return response()->json([ + 'message' => 'Укажите email или телефон субъекта.', + 'errors' => ['subject_email' => ['Необходимо email или телефон.']], + ], 422); + } + + $now = CarbonImmutable::now(); + + // NB: deadline_at заполняется триггером trg_pd_subject_requests_deadline + // (received_at + 30 дней). Передаём placeholder — триггер перезапишет. + $id = DB::connection('pgsql_supplier') + ->table('pd_subject_requests') + ->insertGetId([ + 'received_at' => $now, + 'subject_email' => $validated['subject_email'] ?? null, + 'subject_phone' => $validated['subject_phone'] ?? null, + 'subject_full_name' => $validated['subject_full_name'] ?? null, + 'request_type' => $validated['request_type'], + 'description' => $validated['description'] ?? null, + 'status' => 'received', + 'tenant_id' => $validated['tenant_id'] ?? null, + 'processing_restricted' => false, + // deadline_at: trigger перезапишет, но NOT NULL требует значения + 'deadline_at' => $now->addDays(30), + ]); + + $row = DB::connection('pgsql_supplier') + ->table('pd_subject_requests') + ->where('id', $id) + ->first(); + + return response()->json(['data' => $this->formatRow($row)], 201); + } + + /** + * POST /api/admin/pd-subject-requests/{id}/erase + * + * Выполнить анонимизацию ПДн для обращения с request_type='deletion'. + * Возвращает counts анонимизированных записей. + */ + public function executeErasure(int $id, Request $request): JsonResponse + { + $row = DB::connection('pgsql_supplier') + ->table('pd_subject_requests') + ->where('id', $id) + ->first(); + + if ($row === null) { + return response()->json(['message' => 'Обращение не найдено.'], 404); + } + + if ($row->request_type !== 'deletion') { + return response()->json([ + 'message' => 'Анонимизация доступна только для обращений типа "deletion".', + ], 422); + } + + if ($row->status === 'completed') { + return response()->json([ + 'message' => 'Обращение уже выполнено.', + ], 422); + } + + if (empty($row->subject_email) && empty($row->subject_phone)) { + return response()->json([ + 'message' => 'В обращении не указан email или телефон субъекта.', + ], 422); + } + + $adminId = $this->resolveAdminUserId( + $request, + 'pd-erasure-stub@system.local', + 'PD Erasure System', + ); + + $counts = $this->erasureService->eraseSubject( + email: $row->subject_email ?: null, + phone: $row->subject_phone ?: null, + tenantId: $row->tenant_id !== null ? (int) $row->tenant_id : null, + actorAdminId: $adminId, + requestId: (string) $id, + ); + + return response()->json([ + 'message' => 'Анонимизация выполнена.', + 'counts' => $counts, + ]); + } + + /** + * Форматировать строку pd_subject_requests в массив для API. + * + * @return array + */ + private function formatRow(object $row): array + { + return [ + 'id' => (int) $row->id, + 'received_at' => $row->received_at !== null + ? CarbonImmutable::parse($row->received_at)->toIso8601String() : null, + 'subject_email' => $row->subject_email, + 'subject_phone' => $row->subject_phone, + 'subject_full_name' => $row->subject_full_name, + 'request_type' => $row->request_type, + 'description' => $row->description, + 'status' => $row->status, + 'tenant_id' => $row->tenant_id !== null ? (int) $row->tenant_id : null, + 'assigned_admin_id' => $row->assigned_admin_id !== null + ? (int) $row->assigned_admin_id : null, + 'response_text' => $row->response_text, + 'deadline_at' => $row->deadline_at !== null + ? CarbonImmutable::parse($row->deadline_at)->toIso8601String() : null, + 'completed_at' => $row->completed_at !== null + ? CarbonImmutable::parse($row->completed_at)->toIso8601String() : null, + 'processing_restricted' => (bool) $row->processing_restricted, + ]; + } +} diff --git a/app/app/Models/PdSubjectRequest.php b/app/app/Models/PdSubjectRequest.php new file mode 100644 index 00000000..ebcbe5f0 --- /dev/null +++ b/app/app/Models/PdSubjectRequest.php @@ -0,0 +1,81 @@ + */ + protected $fillable = [ + 'received_at', + 'subject_email', + 'subject_phone', + 'subject_full_name', + 'request_type', + 'description', + 'status', + 'tenant_id', + 'assigned_admin_id', + 'response_sent_at', + 'response_text', + 'deadline_at', + 'completed_at', + 'processing_restricted', + ]; + + /** @var array */ + protected $casts = [ + 'received_at' => 'datetime', + 'response_sent_at' => 'datetime', + 'deadline_at' => 'datetime', + 'completed_at' => 'datetime', + 'processing_restricted' => 'boolean', + 'tenant_id' => 'integer', + 'assigned_admin_id' => 'integer', + ]; + + /** Тенант, к которому относится обращение (nullable). */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * SaaS-админ, назначенный исполнителем. + * + * NB: модель SaasAdminUser не создана — используем User как фиктивный базис. + * В реальном коде — DB::table('saas_admin_users') напрямую в контроллере. + */ + // assignedAdmin: нет Eloquent-модели SaasAdminUser — читается напрямую через DB +} diff --git a/app/app/Services/Pd/PdErasureService.php b/app/app/Services/Pd/PdErasureService.php new file mode 100644 index 00000000..9852bb05 --- /dev/null +++ b/app/app/Services/Pd/PdErasureService.php @@ -0,0 +1,257 @@ + 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0]; + + DB::connection(self::DB)->transaction(function () use ( + $email, $phone, $tenantId, $actorAdminId, $requestId, &$counts + ): void { + $now = CarbonImmutable::now(); + + // ------------------------------------------------------------------ + // 1. users + // ------------------------------------------------------------------ + $userQuery = DB::connection(self::DB)->table('users'); + $userQuery->where(function ($q) use ($email, $phone): void { + if ($email !== null) { + $q->orWhere('email', $email); + } + if ($phone !== null) { + $q->orWhere('phone', $phone); + } + }); + if ($tenantId !== null) { + $userQuery->where('tenant_id', $tenantId); + } + + $users = $userQuery->get(['id', 'tenant_id']); + + foreach ($users as $user) { + $userId = (int) $user->id; + $userTenantId = (int) $user->tenant_id; + + DB::connection(self::DB)->table('users') + ->where('id', $userId) + ->update([ + 'email' => 'erased-'.$userId.'@deleted.local', + 'first_name' => 'Удалено', + 'last_name' => null, + 'phone' => '+7000'.str_pad((string) $userId, 7, '0', STR_PAD_LEFT), + 'updated_at' => $now, + ]); + + $this->writePdLog( + tenantId: $userTenantId, + subjectType: 'user', + subjectId: $userId, + actorAdminId: $actorAdminId, + now: $now, + ); + } + + $counts['users'] = $users->count(); + + // ------------------------------------------------------------------ + // 2. supplier_leads (phone + raw_payload JSONB) + // NB: нет contact_email / contact_phone — поиск только по phone + // ------------------------------------------------------------------ + $leadQuery = DB::connection(self::DB)->table('supplier_leads'); + if ($phone !== null) { + $leadQuery->where('phone', $phone); + } else { + // Только email — ищем в raw_payload JSONB + $leadQuery->whereRaw('raw_payload::text LIKE ?', ['%'.$email.'%']); + } + + $leads = $leadQuery->get(['id']); + + foreach ($leads as $lead) { + $leadId = (int) $lead->id; + + DB::connection(self::DB)->table('supplier_leads') + ->where('id', $leadId) + ->update([ + 'phone' => '+7000XXXXXXX', + 'raw_payload' => DB::connection(self::DB)->raw( + "JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)" + ), + ]); + + $this->writePdLog( + tenantId: $tenantId, + subjectType: 'lead', + subjectId: $leadId, + actorAdminId: $actorAdminId, + now: $now, + ); + } + + $counts['leads'] = $leads->count(); + + // ------------------------------------------------------------------ + // 3. deals (phone + contact_name) + // Deals партиционированы — UPDATE без WHERE на партиции через + // parent table работает начиная с PG 11+. + // ------------------------------------------------------------------ + $dealQuery = DB::connection(self::DB)->table('deals'); + $dealQuery->where(function ($q) use ($email, $phone): void { + if ($phone !== null) { + $q->orWhere('phone', $phone); + } + if ($email !== null) { + // Дополнительно: UTM/phones JSONB может хранить email, но в + // минимуме ищем только по phone. Email в deals не хранится + // в отдельной колонке. + } + }); + if ($tenantId !== null) { + $dealQuery->where('tenant_id', $tenantId); + } + // Исключаем строки без совпадения по phone (когда phone=null — ничего не ищем) + if ($phone === null) { + // deals не имеет email-колонки, пропускаем + $dealQuery->whereRaw('FALSE'); + } + + $deals = $dealQuery->get(['id']); + + foreach ($deals as $deal) { + $dealId = (int) $deal->id; + + DB::connection(self::DB)->table('deals') + ->where('id', $dealId) + ->update([ + 'phone' => '+7000XXXXXXX', + 'contact_name' => 'Удалено', + 'updated_at' => $now, + ]); + } + + $counts['deals'] = $deals->count(); + + // ------------------------------------------------------------------ + // 4. webhook_log (raw_payload JSONB text-search) + // ------------------------------------------------------------------ + $wlQuery = DB::connection(self::DB)->table('webhook_log'); + $conditions = []; + $bindings = []; + if ($email !== null) { + $conditions[] = 'raw_payload::text LIKE ?'; + $bindings[] = '%'.$email.'%'; + } + if ($phone !== null) { + $conditions[] = 'raw_payload::text LIKE ?'; + $bindings[] = '%'.$phone.'%'; + } + + if (! empty($conditions)) { + $wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings); + } + + if ($tenantId !== null) { + $wlQuery->where('tenant_id', $tenantId); + } + + // Batched update: обрабатываем по 500 строк + $wlCount = 0; + $wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void { + $ids = $rows->pluck('id')->all(); + DB::connection(self::DB)->table('webhook_log') + ->whereIn('id', $ids) + ->update([ + 'raw_payload' => DB::connection(self::DB)->raw( + "JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)" + ), + ]); + $wlCount += count($ids); + }); + + $counts['webhook_log'] = $wlCount; + + // ------------------------------------------------------------------ + // 5. Обновить pd_subject_requests если requestId передан + // ------------------------------------------------------------------ + if ($requestId !== null) { + $summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, " + ."deals={$counts['deals']}, webhook_log={$counts['webhook_log']}"; + + DB::connection(self::DB)->table('pd_subject_requests') + ->where('id', $requestId) + ->update([ + 'status' => 'completed', + 'completed_at' => $now, + 'response_text' => $summary, + ]); + } + }); + + return $counts; + } + + /** + * Вставить запись в pd_processing_log через BYPASSRLS-соединение. + */ + private function writePdLog( + ?int $tenantId, + string $subjectType, + int $subjectId, + int $actorAdminId, + CarbonImmutable $now, + ): void { + DB::connection(self::DB)->table('pd_processing_log')->insert([ + 'tenant_id' => $tenantId, + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'action' => 'deleted', + 'purpose' => '152-FZ erasure', + 'actor_admin_user_id' => $actorAdminId, + 'created_at' => $now, + ]); + } +} diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index bd4ff789..243ce795 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -494,3 +494,68 @@ export async function updateAdminSupplier( const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload); return data.data; } + +// --------------------------------------------------------------------------- +// 152-ФЗ: обращения субъектов ПДн +// --------------------------------------------------------------------------- + +export interface PdSubjectRequest { + id: number; + received_at: string; + subject_email: string | null; + subject_phone: string | null; + subject_full_name: string | null; + request_type: 'access' | 'rectification' | 'deletion' | 'objection'; + description: string | null; + status: 'received' | 'in_progress' | 'completed' | 'rejected'; + tenant_id: number | null; + assigned_admin_id: number | null; + response_text: string | null; + deadline_at: string; + completed_at: string | null; + processing_restricted: boolean; +} + +export interface ListPdRequestsResponse { + data: PdSubjectRequest[]; + total: number; + limit: number; + offset: number; +} + +export interface CreatePdRequestPayload { + subject_email?: string; + subject_phone?: string; + subject_full_name?: string; + request_type: 'access' | 'rectification' | 'deletion' | 'objection'; + description?: string; + tenant_id?: number | null; +} + +export interface EraseSubjectResult { + message: string; + counts: { users: number; leads: number; deals: number; webhook_log: number }; +} + +export async function listPdSubjectRequests( + params: { status?: string; request_type?: string; limit?: number; offset?: number } = {}, +): Promise { + const { data } = await apiClient.get('/api/admin/pd-subject-requests', { params }); + return data; +} + +export async function createPdSubjectRequest(payload: CreatePdRequestPayload): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ data: PdSubjectRequest }>('/api/admin/pd-subject-requests', payload); + return data.data; +} + +export async function executePdErasure(id: number, adminUserId?: number): Promise { + await ensureCsrfCookie(); + const payload = adminUserId !== undefined ? { admin_user_id: adminUserId } : {}; + const { data } = await apiClient.post( + `/api/admin/pd-subject-requests/${id}/erase`, + payload, + ); + return data; +} diff --git a/app/resources/js/layouts/AdminLayout.vue b/app/resources/js/layouts/AdminLayout.vue index cb36e5b4..b27f77fe 100644 --- a/app/resources/js/layouts/AdminLayout.vue +++ b/app/resources/js/layouts/AdminLayout.vue @@ -34,6 +34,7 @@ const navItems: NavItem[] = [ { title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' }, { title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' }, { title: 'Проекты у поставщика', icon: 'mdi-format-list-checks', to: '/admin/supplier-projects' }, + { title: 'Обращения ПДн (152-ФЗ)', icon: 'mdi-shield-account-outline', to: '/admin/pd-subject-requests' }, ]; const route = useRoute(); diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index f2a4bae8..d085aa53 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -295,6 +295,18 @@ const routes: RouteRecordRaw[] = [ devLabel: 'Admin Supplier Projects', }, }, + { + path: '/admin/pd-subject-requests', + name: 'admin-pd-subject-requests', + component: () => import('../views/admin/AdminPdSubjectRequestsView.vue'), + meta: { + layout: 'admin', + title: 'Обращения ПДн', + requiresAuth: true, + devIndex: 32, + devLabel: 'Admin PD Requests', + }, + }, // Error pages: 403/500 явные + catch-all 404 (всегда последний). { path: '/403', diff --git a/app/resources/js/views/admin/AdminPdSubjectRequestsView.vue b/app/resources/js/views/admin/AdminPdSubjectRequestsView.vue new file mode 100644 index 00000000..19d3a062 --- /dev/null +++ b/app/resources/js/views/admin/AdminPdSubjectRequestsView.vue @@ -0,0 +1,498 @@ + + + + + diff --git a/app/routes/web.php b/app/routes/web.php index 1d5e19ea..320d511e 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -162,6 +162,16 @@ Route::middleware('saas-admin')->group(function () { // Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete. Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex'); Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy'); + + // 152-ФЗ: обращения субъектов ПДн + анонимизация (дыра #4). + Route::prefix('/api/admin/pd-subject-requests')->group(function () { + Route::get('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@index'); + Route::post('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@store'); + Route::get('/{id}', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@show') + ->where('id', '[0-9]+'); + Route::post('/{id}/erase', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@executeErasure') + ->where('id', '[0-9]+'); + }); }); // Plan 4 Task 11: tenant charges ledger (read-only + CSV export). diff --git a/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php b/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php new file mode 100644 index 00000000..1591fdbf --- /dev/null +++ b/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php @@ -0,0 +1,329 @@ +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', + 'webhook_token' => bin2hex(random_bytes(16)), + '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); +});