Files
portal/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php
T
Дмитрий 6e1f5355b8 refactor(webhook): Phase 4 — DROP migration + schema v8.35 + test/factory cleanup
Task 4.1 Steps 1–7: legacy direct webhook channel DDL removal.

Migration 2026_05_24_140000_drop_legacy_webhook_artefacts:
- DROP TABLE webhook_log CASCADE (partitioned RANGE по received_at)
- DROP TABLE rejected_deals_log CASCADE
- ALTER TABLE tenants DROP COLUMN webhook_token, webhook_token_rotated_at
- DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'
NB: webhook_dedup_keys ОСТАВЛЕНА — используется CSV-каналом (HistoricalImportService).

Services fixed (не покрыты Phase 3):
- MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
- PdErasureService::eraseSubject() — убрана секция 4 (SELECT/UPDATE webhook_log)

Factory + tests cleanup (webhook_token column gone):
- TenantFactory: убрано webhook_token из definition()
- 7 test files: убраны вставки webhook_token в DB::table('tenants')->insert(...)
- storage/_demo_split_tenants.php: убрана строка webhook_token

Schema v8.35:
- −2 таблицы (webhook_log partitioned + rejected_deals_log)
- −5 индексов (idx_webhook_log_*, idx_rejected_*, idx_tenants_webhook_token)
- −2 RLS-политики
- db/CHANGELOG_schema.md: запись v8.35

Tests updated:
- SchemaDeltaTest: 66 base tables / 120 indexes / 40 RLS policies
- PartitionsCreateMonthsTest: webhook_log убрана из regex / 48 skipped вместо 54

Smoke: 36/36 passed (RlsSmoke, AdminBilling, AdminPdSubject, PartitionsCreateMonths, SchemaDelta).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:17 +03:00

329 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
});