Files
portal/app/tests/Frontend/ImpersonationDialog.spec.ts
T
Дмитрий 61afa72591 phase2(impersonation-ui): UI dialog для Ю-1 в AdminTenantsView (frontend)
Закрывает TODO из v1.44 — frontend для Impersonation backend (`1963694`).

api/admin.ts:
- impersonationInit/Verify/End — типизированные axios-helpers для трёх
  endpoint из v1.53. Все три — ensureCsrfCookie + apiClient.post.
  На prod автоматически перейдут под middleware('auth:saas-admin').

components/admin/ImpersonationDialog.vue — 4-step state-machine:
- step 1 «reason»: v-textarea ≥30 chars + counter + hint «Ещё N символов».
- step 2 «verify»: info-alert email клиента + 6-digit input
  (autocomplete=one-time-code) + dev-banner с _dev_plain_code.
- step 3 «active»: success-alert + кнопка «Завершить сессию».
- step 4 «done»: финальный success.
- persistent dialog (нельзя закрыть кликом за пределами — audit trail).
- watch(modelValue) сбрасывает state при каждом открытии.

AdminTenantsView:
- 8-я колонка actions (width=56) с v-tooltip + icon-btn mdi-account-switch.
- :disabled на suspended (по ТЗ §22.7 — только активные tenant'ы).
- @click.stop, data-testid=impersonate-btn-{id}.
- ADMIN_USER_ID=1 заглушка (на prod удалится — backend возьмёт из auth).

Vitest +11 (всего 190/190 за 13.23 сек):
- ImpersonationDialog.spec.ts (7): hide когда modelValue=false; step-1 mount;
  reason<30 показывает counter; init→step2 (email+dev-banner); verify→step3
  (end-btn); 5-digit code не вызывает API; end→step4; Cancel emit.
- AdminTenantsView.spec.ts (+4): impersonate-btn в каждой строке; suspended
  disabled; click открывает диалог с правильным tenant; props.requestedBy=1.

Vitest quirk: v-dialog/v-tooltip требуют layout-injection — stub'ы
VDialog как passthrough <div v-if="modelValue"><slot/></div>, VTooltip как
<div><slot name="activator" :props="{}"/></div>. ImpersonationDialog
stub'ится в AdminTenantsView spec. api/admin + helpers extractValidationErrors/
extractErrorMessage мокаются через vi.mock — axios.isAxiosError(plain Error)
в jsdom возвращает false (паттерн из auth-store.spec.ts).

Production TODO: SaaS-admin auth (Yandex 360 SSO, Б-1) → middleware,
two-person approval (CTO-15/Ю-9), MailService → _dev_plain_code исчезает,
live cookie-swap session, страница «Активные impersonation-сессии».

Регресс: lint+type-check+format+build OK (924 ms; AdminTenantsView lazy-chunk
20.68 KB включает inline ImpersonationDialog); Vitest 190/190 за 13.23 сек;
Pest 120/120 за 15.69 сек (нетронут). Реестр v1.53→v1.54, CLAUDE.md v1.44→v1.45.

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

202 lines
9.2 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
// Мокаем api/admin до import'а компонента — иначе реальный axios вызывает
// сетевые запросы в jsdom-runtime.
vi.mock('../../resources/js/api/admin', () => ({
impersonationInit: vi.fn(),
impersonationVerify: vi.fn(),
impersonationEnd: vi.fn(),
}));
// extractValidationErrors / extractErrorMessage используют axios.isAxiosError,
// который в jsdom возвращает false для plain Error → мокаем helper'ы тоже
// (паттерн из auth-store.spec.ts).
vi.mock('../../resources/js/api/client', () => ({
extractValidationErrors: vi.fn(() => null),
extractErrorMessage: vi.fn((_e, fallback?: string) => fallback ?? 'err'),
apiClient: {},
ensureCsrfCookie: vi.fn(),
}));
import * as adminApi from '../../resources/js/api/admin';
import ImpersonationDialog from '../../resources/js/components/admin/ImpersonationDialog.vue';
import { MOCK_TENANTS } from '../../resources/js/composables/mockTenants';
/**
* v-dialog требует layout-injection (v-app/v-layout) — в Vitest без auto-import
* это невозможно. Stub'им VDialog как passthrough div, чтобы slot рендерился.
*/
const factory = (props: { modelValue: boolean; tenant: (typeof MOCK_TENANTS)[number] | null }) =>
mount(ImpersonationDialog, {
props: { ...props, requestedBy: 1 },
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
VTooltip: {
template: '<div><slot name="activator" :props="{}" /></div>',
},
},
},
});
const sampleTenant = MOCK_TENANTS[0]; // Окна Москва ООО
describe('ImpersonationDialog.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('не рендерит content когда modelValue=false', () => {
const wrapper = factory({ modelValue: false, tenant: sampleTenant });
expect(wrapper.find('.dialog-stub').exists()).toBe(false);
});
it('step 1 — заголовок «Войти как клиент» + tenant info + textarea reason', () => {
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
const text = wrapper.text();
expect(text).toContain('Войти как клиент');
expect(text).toContain(sampleTenant.name);
expect(text).toContain(sampleTenant.code);
expect(text).toContain('ИНН ' + sampleTenant.inn);
expect(wrapper.find('[data-testid="reason-input"]').exists()).toBe(true);
// submit-init disabled при пустом reason
expect(wrapper.find('[data-testid="submit-init-btn"]').exists()).toBe(true);
});
it('reason < 30 chars — submit disabled, отображается remaining counter', async () => {
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
const textarea = wrapper.find('[data-testid="reason-input"] textarea');
await textarea.setValue('коротко');
await wrapper.vm.$nextTick();
// Hint показывает сколько осталось до 30 chars
expect(wrapper.text()).toMatch(/Ещё\s+\d+\s+символов/);
});
it('успешный init → step verify → показывает email + dev-code banner', async () => {
vi.mocked(adminApi.impersonationInit).mockResolvedValue({
token_id: 42,
expires_at: '2026-05-09T12:00:00Z',
sent_to_email: 'admin@okna-moscow.ru',
_dev_plain_code: '123456',
});
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
const textarea = wrapper.find('[data-testid="reason-input"] textarea');
await textarea.setValue('Тикет SUP-12453: клиент сообщил, что в карточке сделки не сохраняется коммент.');
await wrapper.find('[data-testid="submit-init-btn"]').trigger('click');
await flushPromises();
expect(adminApi.impersonationInit).toHaveBeenCalledWith({
tenant_id: sampleTenant.id,
requested_by: 1,
reason: expect.stringContaining('Тикет SUP-12453'),
});
// step 2 → email + dev-code visible
const text = wrapper.text();
expect(text).toContain('Код отправлен на email клиента');
expect(text).toContain('admin@okna-moscow.ru');
expect(wrapper.find('[data-testid="dev-code-banner"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="dev-code-banner"]').text()).toContain('123456');
expect(wrapper.find('[data-testid="code-input"]').exists()).toBe(true);
});
it('verify success → step active → кнопка «Завершить сессию»', async () => {
vi.mocked(adminApi.impersonationInit).mockResolvedValue({
token_id: 42,
expires_at: '2026-05-09T12:00:00Z',
sent_to_email: 'admin@okna-moscow.ru',
_dev_plain_code: '111222',
});
vi.mocked(adminApi.impersonationVerify).mockResolvedValue({
token_id: 42,
tenant_id: sampleTenant.id,
used_at: '2026-05-09T11:30:00Z',
message: 'OK',
});
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
// Step 1 → 2
await wrapper.find('[data-testid="reason-input"] textarea').setValue('A'.repeat(35));
await wrapper.find('[data-testid="submit-init-btn"]').trigger('click');
await flushPromises();
// Step 2 → 3
await wrapper.find('[data-testid="code-input"] input').setValue('111222');
await wrapper.find('[data-testid="submit-verify-btn"]').trigger('click');
await flushPromises();
expect(adminApi.impersonationVerify).toHaveBeenCalledWith({ token_id: 42, code: '111222' });
expect(wrapper.text()).toContain('Impersonation активен');
expect(wrapper.find('[data-testid="submit-end-btn"]').exists()).toBe(true);
});
it('verify с не-6-цифрами не вызывает API + показывает ошибку', async () => {
vi.mocked(adminApi.impersonationInit).mockResolvedValue({
token_id: 42,
expires_at: '2026-05-09T12:00:00Z',
sent_to_email: 'admin@example.ru',
_dev_plain_code: '999999',
});
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
await wrapper.find('[data-testid="reason-input"] textarea').setValue('A'.repeat(35));
await wrapper.find('[data-testid="submit-init-btn"]').trigger('click');
await flushPromises();
await wrapper.find('[data-testid="code-input"] input').setValue('12345'); // 5 цифр
await wrapper.find('[data-testid="submit-verify-btn"]').trigger('click');
await flushPromises();
expect(adminApi.impersonationVerify).not.toHaveBeenCalled();
expect(wrapper.text()).toContain('Введите 6 цифр');
});
it('end success → step done → кнопка «Закрыть»', async () => {
vi.mocked(adminApi.impersonationInit).mockResolvedValue({
token_id: 42,
expires_at: '2026-05-09T12:00:00Z',
sent_to_email: 'admin@example.ru',
_dev_plain_code: '777777',
});
vi.mocked(adminApi.impersonationVerify).mockResolvedValue({
token_id: 42,
tenant_id: sampleTenant.id,
used_at: '2026-05-09T11:30:00Z',
message: 'OK',
});
vi.mocked(adminApi.impersonationEnd).mockResolvedValue({
token_id: 42,
session_ended_at: '2026-05-09T12:00:00Z',
message: 'Завершён',
});
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
await wrapper.find('[data-testid="reason-input"] textarea').setValue('A'.repeat(35));
await wrapper.find('[data-testid="submit-init-btn"]').trigger('click');
await flushPromises();
await wrapper.find('[data-testid="code-input"] input').setValue('777777');
await wrapper.find('[data-testid="submit-verify-btn"]').trigger('click');
await flushPromises();
await wrapper.find('[data-testid="submit-end-btn"]').trigger('click');
await flushPromises();
expect(adminApi.impersonationEnd).toHaveBeenCalledWith(42);
expect(wrapper.text()).toContain('Сессия impersonation завершена');
expect(wrapper.find('[data-testid="close-btn"]').exists()).toBe(true);
});
it('Отмена на step reason эмитит update:modelValue=false', async () => {
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
await wrapper.find('[data-testid="cancel-btn"]').trigger('click');
const events = wrapper.emitted('update:modelValue');
expect(events).toBeDefined();
expect(events?.[0]).toEqual([false]);
});
});