77e98afaa6
Закрывает дыру #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).
330 lines
12 KiB
PHP
330 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',
|
||
'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);
|
||
});
|