61afa72591
Закрывает 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>
202 lines
9.2 KiB
TypeScript
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]);
|
|
});
|
|
});
|