Files
portal/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php
T
Дмитрий 88ace4e3d9
Accessibility (Pa11y live) / a11y (push) Has been cancelled
test: дозакрытие оздоровления — protekateli pd-аудита, видимость supplier, новый флоу регистрации
Снижение остатка 19 to 5. Всё тест-сторона:
- PdErasureServiceTest + AdminPdSubjectRequestsControllerTest: SharesSupplierPdo —
  перестали коммитить pd_processing_log через pgsql_supplier, что ломало
  глобальный audit:verify-chains (6 падений) и амплифицировало PhoneRegionSmoke.
- ReportFileDeletePdLogTest: SharesSupplierPdo — cron reports:cleanup-expired
  теперь видит незакоммиченные job'ы теста.
- AdminSuppliersControllerTest: устойчивый ассерт (с фазы 3 в suppliers есть direct).
- AuthLogCoverageTest/AuthFlowIntegrationTest: новый флоу самозаписи G1/SP1 —
  register_success пишется после confirm-email; добавлен шаг подтверждения.
- ImpersonationTest end: verify (G7-B) ставит маркер impersonation → admin-зона
  закрыта by design; помечаем токен used напрямую вместо session-takeover.
- CleanupInactiveSupplierProjectsJobTest: phase A читает pivot project_supplier_links —
  добавлена привязка linkProjectToSupplier (раньше был только legacy FK).
- Pint-нормализация uses() FQN to import в ранее тронутых файлах.

Остаток 5 (НЕ слепой патч): webhook B-префикс ×2 (решение владельца), advisory-lock
audit-цепочки (возможный дрейф схемы, флажок), SupplierConnection WARN#2 (cap-3,
поведенческое), SupplierPortalClientTest (пре-существующий, не от этих правок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:19:53 +03:00

333 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;
use Tests\Concerns\SharesSupplierPdo;
// /erase эндпоинт пишет pd_processing_log через pgsql_supplier; без SharesSupplierPdo
// строки коммитятся (откатывается только default pgsql) и ломают глобальный
// audit:verify-chains в других тестах. Также шарит committed admin/tenant/user строки.
uses(DatabaseTransactions::class, SharesSupplierPdo::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);
});