Files
portal/app/tests/Feature/ImpersonationTest.php
T
Дмитрий 768628d914 phase2(7-features): bulk-actions / new-deal / tenant-card / system-edit / webhook / smart-filters / impersonation-list
7-фичный auto-mode пакет согласно «карте что осталось» (после v1.54).

(1) Bulk-actions DealsView:
- dealsState reactive-копия MOCK_DEALS (deep-clone) для безопасного bulk-edit.
- Bulk-bar (sticky, теало-нуар, theme=dark) при selected.length > 0:
  count + Сменить статус (v-menu × 14 lead_statuses) + Экспорт (snackbar) +
  Удалить (v-dialog confirm) + ✕ clear.
- На production: smart status-transition с проверкой allowed-переходов;
  soft-delete (архив 30 дней); реальный CSV/XLSX export через xlsx-lib.

(2) NewDealDialog (used in DealsView+KanbanView):
- 6 полей: name/phone/project (MOCK_PROJECTS) / manager (MOCK_MANAGERS) /
  cost / status (default 'new' или presetStatus). Phone-валидация ≥10 цифр.
- emit('created', deal) → DealsView push в начало dealsState; KanbanView push
  в правильную колонку по statusSlug + totalDeals++.

(3) AdminTenantDetailView (/admin/tenants/:code):
- 4 KPI cards (Баланс/runway / Тариф+MRR/мес / Лиды сегодня+неделя+месяц /
  Средняя цена). 4 v-tabs: Финансы (balance-history) / Пользователи /
  Проекты / Активность с event-кодами.
- Кнопка «Войти как клиент» (использует ImpersonationDialog из v1.54).
  404-fallback. composables/mockTenantDetail.ts с expandTenantDetail.
- AdminTenantsView получил @click:row → router.push.

(4) Edit-flow AdminSystemView (audit-log + 2-step):
- Backend: SystemSetting + SaasAdminAuditLog Eloquent (append-only,
  payload_before/after JSONB casts).
- AdminSystemSettingsController с GET (list) + PUT (update в DB::transaction
  + INSERT в saas_admin_audit_log; hash-chain trigger BEFORE INSERT
  заполняет log_hash).
- Type-validation: int/decimal/bool/json. Reason ≥30 chars. No-op → 422.
- Frontend SystemSettingEditDialog — 3-step (edit → confirm с diff
  before/after → done).

(5) Webhook receive endpoint (POST /api/webhook/{token}):
- WebhookReceiveController::receive. Token = tenants.webhook_token.
- 404 unknown / 422 bad payload / 202 success + dispatch ProcessWebhookJob.
- Stub-INSERT в webhook_log через DB::table обёрнут в DB::transaction +
  SET LOCAL app.current_tenant_id для RLS.
- CSRF-исключение для api/webhook/* в bootstrap/app.php.
- На prod: + HMAC X-Webhook-Signature + per-token rate-limit.

(6) Smart-filters:
- DealsView: multi-select v-select Проект+Менеджер с auto availableProjects/
  availableManagers computed.
- AdminTenantsView: filterStatuses (4 STATUS_OPTIONS) + filterTariffs
  (computed availableTariffs).
- Кнопка «Сбросить» появляется только когда фильтры активны.

(7) AdminImpersonationView (/admin/impersonation):
- Backend +2 GET endpoints: /active (used_at != null AND session_ended_at
  == null) + /recent (last 20 завершённых с duration_seconds через
  abs(diffInSeconds) — Carbon signed по умолчанию).
- ImpersonationToken получил belongsTo(Tenant).
- Frontend view: 2 секции (Активные с end-кнопкой / Недавно завершённые
  read-only) + refresh + onMounted load.
- Маршрут /admin/impersonation + 5-й nav-пункт «Impersonation» в AdminLayout.

Vitest +48 (всего 238/238 за 15.31 сек).
Pest +16 (всего 136/136 за 15.8 сек, 495 assertions).
PHPStan baseline регенерирован (0 errors после фикса nullsafe.neverNull).

Регресс: lint+type-check+format ; vite build 937 ms; Pint+PHPStan passed;
Pest 136/136. Реестр v1.54→v1.55, CLAUDE.md v1.45→v1.46.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 05:33:21 +03:00

271 lines
10 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;
uses(DatabaseTransactions::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('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);
});