Files
portal/app/tests/Feature/ImpersonationTest.php
T
Дмитрий b32dfbcdc1 fix(impersonation): SaaS-admin запросы через pgsql_supplier (BYPASSRLS) — лечит RLS 42704 на проде
ImpersonationController читал/писал impersonation_tokens+tenants через дефолтное подключение (crm_app_user, RLS on). У saas-admin нет tenant-контекста (middleware 'tenant' на /api/admin/* не висит) -> app.current_tenant_id не задан -> SELECT падал SQLSTATE 42704. На dev маскировалось postgres-superuser'ом. Фикс: запросы к impersonation_tokens/tenants через BYPASSRLS pgsql_supplier (как AdminSupplierIntegrationController; модель уже документирует BYPASSRLS-доступ). Транзакция в verify() убрана — increment атомарен, isUsable() гейтит attempts<5. Тест: +SharesSupplierPdo + regression на подключение; baseline getJson 2->3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:40:16 +03:00

290 lines
11 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 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',
]);
// Минимальный 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);
});