Files
portal/app/tests/Feature/ImpersonationTest.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

293 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\ImpersonationToken;
use App\Models\Tenant;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// SaaS-admin impersonation запрашивает impersonation_tokens/tenants через
// BYPASSRLS-подключение pgsql_supplier (RLS-фикс). Под DatabaseTransactions
// данные default-подключения не видны pgsql_supplier до commit'а → SharesSupplierPdo
// шарит PDO между подключениями (как в tests/Feature/Supplier/*).
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create([
'contact_email' => 'tenant-admin@example.ru',
]);
// verify() делает session-takeover активного юзера тенанта (G7-B 19.06.2026):
// без активного юзера контроллер отдаёт 422 «нет активного пользователя».
User::factory()->create(['tenant_id' => $this->tenant->id, 'is_active' => true]);
// Минимальный saas_admin_user через DB::table — factory нет.
$this->adminId = DB::table('saas_admin_users')->insertGetId([
'email' => 'admin-saas@liderra.ru',
'full_name' => 'SaaS Admin',
'password_hash' => '$2y$04$dummy-hash-for-test',
'role' => 'support',
'is_active' => true,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
});
test('GET /api/admin/impersonation/active возвращает активные сессии (used_at != null AND session_ended_at == null)', function () {
// Создаём 3 токена в разных state'ах:
// 1. pending (used_at=null) — НЕ должен быть в выдаче
ImpersonationToken::create([
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'code_hash' => 'pending',
'reason' => 'pending session '.str_repeat('x', 30),
'sent_to_email' => 'a@b.ru',
'expires_at' => now()->addMinutes(15),
]);
// 2. active (used_at != null, session_ended_at = null) — ДОЛЖЕН быть
$active = ImpersonationToken::create([
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'code_hash' => 'active',
'reason' => 'active session '.str_repeat('y', 30),
'sent_to_email' => 'c@d.ru',
'expires_at' => now()->addMinutes(15),
'used_at' => now()->subMinutes(5),
]);
// 3. completed (session_ended_at != null) — НЕ должен быть
ImpersonationToken::create([
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'code_hash' => 'completed',
'reason' => 'completed session '.str_repeat('z', 30),
'sent_to_email' => 'e@f.ru',
'expires_at' => now()->subMinutes(45),
'used_at' => now()->subMinutes(40),
'session_ended_at' => now()->subMinutes(10),
]);
$r = $this->getJson('/api/admin/impersonation/active');
$r->assertStatus(200);
$sessions = $r->json('sessions');
expect($sessions)->toHaveCount(1);
expect($sessions[0]['token_id'])->toBe($active->id);
expect($sessions[0]['reason'])->toContain('active session');
});
test('active() читает impersonation_tokens через BYPASSRLS-подключение pgsql_supplier (regression RLS-фикс)', function () {
$connections = [];
DB::listen(function ($query) use (&$connections) {
if (str_contains($query->sql, 'impersonation_tokens')) {
$connections[] = $query->connectionName;
}
});
$this->getJson('/api/admin/impersonation/active')->assertStatus(200);
expect($connections)->not->toBeEmpty();
expect(array_values(array_unique($connections)))->toBe(['pgsql_supplier']);
});
test('GET /api/admin/impersonation/recent возвращает завершённые сессии с длительностью', function () {
ImpersonationToken::create([
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'code_hash' => 'r1',
'reason' => 'recent session '.str_repeat('a', 30),
'sent_to_email' => 'a@b.ru',
'expires_at' => now()->subMinutes(60),
'used_at' => now()->subMinutes(50),
'session_ended_at' => now()->subMinutes(20),
]);
$r = $this->getJson('/api/admin/impersonation/recent');
$r->assertStatus(200);
$sessions = $r->json('sessions');
expect($sessions)->toHaveCount(1);
expect($sessions[0]['duration_seconds'])->toBeInt()->toBeGreaterThan(1700); // ~30 мин = 1800 сек
});
test('POST /api/admin/impersonation/init создаёт токен с reason ≥30 + 6-значный код + TTL 15 мин', function () {
$r = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => 'Клиент запросил демонстрацию настроек интеграции после звонка',
]);
$r->assertOk();
expect($r->json('token_id'))->toBeInt();
expect($r->json('sent_to_email'))->toBe('tenant-admin@example.ru');
expect($r->json('_dev_plain_code'))->toMatch('/^\d{6}$/');
// expires_at в окне (now+14min, now+16min).
$expires = Carbon::parse($r->json('expires_at'));
$diffMin = abs($expires->diffInMinutes(now()));
expect($diffMin)->toBeLessThanOrEqual(16);
expect($diffMin)->toBeGreaterThanOrEqual(14);
// Запись в БД.
$token = ImpersonationToken::find($r->json('token_id'));
expect($token->tenant_id)->toBe($this->tenant->id);
expect($token->requested_by)->toBe($this->adminId);
expect($token->code_hash)->not->toBe($r->json('_dev_plain_code')); // hashed
});
test('POST /api/admin/impersonation/init 422 при reason < 30 символов', function () {
$r = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => 'Слишком коротко',
]);
$r->assertStatus(422);
});
test('POST /api/admin/impersonation/init 404 при несуществующем tenant_id', function () {
$r = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => 999999,
'requested_by' => $this->adminId,
'reason' => 'Клиент запросил помощь по очень длинной причине которой хватает',
]);
$r->assertStatus(404);
});
test('POST /api/admin/impersonation/verify с правильным кодом помечает used_at', function () {
$init = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => 'Клиент запросил демонстрацию настроек интеграции после звонка',
])->assertOk();
$tokenId = $init->json('token_id');
$code = $init->json('_dev_plain_code');
$r = $this->postJson('/api/admin/impersonation/verify', [
'token_id' => $tokenId,
'code' => $code,
]);
$r->assertOk();
expect($r->json('tenant_id'))->toBe($this->tenant->id);
expect($r->json('used_at'))->not->toBeNull();
$token = ImpersonationToken::find($tokenId);
expect($token->used_at)->not->toBeNull();
});
test('POST /api/admin/impersonation/verify 422 + increment failed_attempts при неверном коде', function () {
$init = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => 'Клиент запросил демонстрацию настроек интеграции после звонка',
])->assertOk();
$tokenId = $init->json('token_id');
$r = $this->postJson('/api/admin/impersonation/verify', [
'token_id' => $tokenId,
'code' => '000000',
]);
$r->assertStatus(422);
expect($r->json('attempts_remaining'))->toBe(4);
$token = ImpersonationToken::find($tokenId);
expect($token->failed_attempts)->toBe(1);
expect($token->invalidated_at)->toBeNull();
});
test('POST /api/admin/impersonation/verify после 5 неверных → invalidated_at + 422', function () {
$init = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => 'Клиент запросил демонстрацию настроек интеграции после звонка',
])->assertOk();
$tokenId = $init->json('token_id');
for ($i = 1; $i <= 5; $i++) {
$this->postJson('/api/admin/impersonation/verify', [
'token_id' => $tokenId,
'code' => '000000',
])->assertStatus(422);
}
$token = ImpersonationToken::find($tokenId);
expect($token->invalidated_at)->not->toBeNull();
// 6-я попытка — даже с правильным кодом не пройдёт, токен invalidated.
$r = $this->postJson('/api/admin/impersonation/verify', [
'token_id' => $tokenId,
'code' => $init->json('_dev_plain_code'),
]);
$r->assertStatus(422);
expect($r->json('reason'))->toBe('invalidated');
});
test('verify 422 при истёкшем токене', function () {
$init = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => 'Клиент запросил демонстрацию настроек интеграции после звонка',
])->assertOk();
$tokenId = $init->json('token_id');
// Истекаем токен через прямое UPDATE.
DB::table('impersonation_tokens')->where('id', $tokenId)->update([
'expires_at' => now()->subMinutes(5),
]);
$r = $this->postJson('/api/admin/impersonation/verify', [
'token_id' => $tokenId,
'code' => $init->json('_dev_plain_code'),
]);
$r->assertStatus(422);
expect($r->json('reason'))->toBe('expired');
});
test('POST /api/admin/impersonation/end ставит session_ended_at', function () {
$init = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => 'Клиент запросил демонстрацию настроек интеграции после звонка',
])->assertOk();
$tokenId = $init->json('token_id');
// end() требует used_at (иначе 422). Раньше тут звали verify, но с G7-B verify
// делает session-takeover и ставит маркер 'impersonation' в сессии → EnsureSaasAdmin
// блокирует admin-only end с 403 (во время impersonation админка запрещена, by design).
// Помечаем токен использованным напрямую, чтобы проверить именно поведение end.
ImpersonationToken::on('pgsql_supplier')->where('id', $tokenId)->update(['used_at' => now()]);
$r = $this->postJson('/api/admin/impersonation/end', [
'token_id' => $tokenId,
]);
$r->assertOk();
expect($r->json('session_ended_at'))->not->toBeNull();
$token = ImpersonationToken::find($tokenId);
expect($token->session_ended_at)->not->toBeNull();
});
test('end 422 если сессия не была активирована', function () {
$init = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => 'Клиент запросил демонстрацию настроек интеграции после звонка',
])->assertOk();
// Без verify сразу делаем end.
$r = $this->postJson('/api/admin/impersonation/end', [
'token_id' => $init->json('token_id'),
]);
$r->assertStatus(422);
});