Files
portal/app/tests/Feature/ImpersonationTest.php
T
Дмитрий 2ec70b338f
Accessibility (Pa11y live) / a11y (push) Has been cancelled
test: оздоровление тест-стенда — изоляция протекателей плюс фикстуры, партиции, видимость supplier-коннекта
Закрыто 36 из 55 пре-существующих падений backend-набора (55 to 19), всё тест-сторона,
код продукта не тронут. Группы:
- incident-показ/РКН: добавлен SharesSupplierPdo + синхрон уровня транзакции в трейте
  (вложенный transaction на общем PDO теперь делает SAVEPOINT, не повторный BEGIN).
- auto-pause и lead-delivery: тесты создают project_routing_snapshots, от которого
  зависит выбор кандидатов в LeadRouter (slepok-инвариант).
- изоляция 16 протекающих тестов: добавлен DatabaseTransactions (где нужно плюс
  SharesSupplierPdo) — перестали оставлять committed-строки, отравлявшие глобально
  сканирующие тесты (snapshot, verify-audit, size-N).
- partition time-bombs: ensureRange месячных партиций для тестов на дату 2026-05.
- устаревшие ассерты: SchemaDelta метрики v8.35 to v8.52, ProjectsStore телефон 8 to 7
  нормализуется, incidents-watch фильтр активного admin, register captcha_token,
  impersonation активный юзер тенанта, activity_log.deal_id, ProjectUpdateDedup пауза.

Остаток 19 (отдельно): verify-audit-chains и size-N (протекатели audit-строк),
webhook B-префикс (решение владельца), пара env/каскадных.

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

294 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');
$code = $init->json('_dev_plain_code');
// Сначала verify, чтобы токен стал used.
$this->postJson('/api/admin/impersonation/verify', [
'token_id' => $tokenId,
'code' => $code,
])->assertOk();
$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);
});