feat(admin): G5/G6 frontend — incident detail view + РКН-notify

This commit is contained in:
Дмитрий
2026-05-16 13:50:48 +03:00
parent 3269434746
commit ca0c4d9318
7 changed files with 683 additions and 11 deletions
+45
View File
@@ -383,3 +383,48 @@ export async function changeTenantTariff(
);
return data;
}
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===
export interface ApiIncidentAffectedTenant {
id: number;
organization_name: string;
}
export interface ApiAdminIncidentDetail {
id: number;
incident_id: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
summary: string;
root_cause: string | null;
postmortem_url: string | null;
started_at: string;
detected_at: string;
resolved_at: string | null;
status: 'open' | 'investigating' | 'resolved';
affected_tenants: ApiIncidentAffectedTenant[];
affected_users_count: number | null;
notification_sent_at: string | null;
rkn_notified: boolean;
rkn_notified_at: string | null;
rkn_deadline_at: string | null;
created_by_admin: string | null;
closed_by_admin: string | null;
created_at: string | null;
updated_at: string | null;
}
export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
return data.incident;
}
export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
`/api/admin/incidents/${id}/rkn-notify`,
{},
);
return data.incident;
}
+6
View File
@@ -210,6 +210,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminIncidentsView.vue'),
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true, devIndex: 24, devLabel: 'Admin Incidents' },
},
{
path: '/admin/incidents/:id',
name: 'admin-incident-detail',
component: () => import('../views/admin/AdminIncidentDetailView.vue'),
meta: { layout: 'admin', title: 'Инцидент', requiresAuth: true },
},
{
path: '/admin/system',
name: 'admin-system',
@@ -0,0 +1,299 @@
<script setup lang="ts">
/**
* Карточка инцидента (drill-down из AdminIncidentsView).
*
* Sprint 3D G5/G6: детальный просмотр инцидента + кнопка «Уведомить РКН»
* (152-ФЗ — обязательное уведомление РКН для data_breach за 24ч).
*
* Маршрут: /admin/incidents/:id
*/
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getAdminIncidentDetail, notifyIncidentRkn } from '../../api/admin';
import type { ApiAdminIncidentDetail } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const route = useRoute();
const router = useRouter();
const id = computed(() => Number(route.params.id));
const incident = ref<ApiAdminIncidentDetail | null>(null);
const loading = ref(false);
const notFound = ref(false);
const fetchError = ref<string | null>(null);
const rknError = ref('');
const rknLoading = ref(false);
const rknDialog = ref(false);
async function loadIncident(): Promise<void> {
loading.value = true;
fetchError.value = null;
notFound.value = false;
try {
incident.value = await getAdminIncidentDetail(id.value);
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
if (status === 404) {
notFound.value = true;
incident.value = null;
} else {
fetchError.value = extractErrorMessage(e);
}
} finally {
loading.value = false;
}
}
onMounted(() => void loadIncident());
watch(id, () => void loadIncident());
async function confirmRkn(): Promise<void> {
rknLoading.value = true;
rknError.value = '';
try {
incident.value = await notifyIncidentRkn(id.value);
rknDialog.value = false;
} catch (e: unknown) {
rknError.value = extractErrorMessage(e);
// dialog stays open so error is visible
} finally {
rknLoading.value = false;
}
}
function goBack(): void {
void router.push({ name: 'admin-incidents' });
}
// Helpers (copied from AdminIncidentsView for self-containment)
const statusMap: Record<string, { label: string; color: string }> = {
open: { label: 'Открыт', color: 'error' },
investigating: { label: 'Расследуется', color: 'warning' },
resolved: { label: 'Решён', color: 'info' },
closed: { label: 'Закрыт', color: 'success' },
};
function statusInfo(s: string) {
return statusMap[s] ?? { label: s, color: 'default' };
}
const severityMap: Record<string, { label: string; color: string }> = {
critical: { label: 'Critical', color: 'error' },
high: { label: 'High', color: 'warning' },
medium: { label: 'Medium', color: 'info' },
low: { label: 'Low', color: 'success' },
};
function severityInfo(s: string) {
return severityMap[s] ?? { label: s, color: 'default' };
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
defineExpose({
incident,
loading,
notFound,
fetchError,
rknError,
rknLoading,
rknDialog,
loadIncident,
confirmRkn,
});
</script>
<template>
<!-- Loading -->
<v-container v-if="loading" fluid class="pa-6" data-testid="incident-loading">
<v-progress-circular indeterminate color="primary" />
<span class="ml-3 text-medium-emphasis">Загрузка</span>
</v-container>
<!-- Not found -->
<v-container v-else-if="notFound" fluid class="pa-6" data-testid="incident-not-found">
<v-alert type="error" variant="tonal" class="mb-4">
Инцидент <strong>#{{ id }}</strong> не найден.
</v-alert>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">К списку инцидентов</v-btn>
</v-container>
<!-- Fetch error -->
<v-container v-else-if="fetchError" fluid class="pa-6" data-testid="incident-fetch-error">
<v-alert type="warning" variant="tonal" class="mb-4">
Не удалось загрузить инцидент: {{ fetchError }}
</v-alert>
<div class="d-flex ga-2">
<v-btn variant="outlined" prepend-icon="mdi-refresh" @click="loadIncident">Повторить</v-btn>
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="goBack">К списку</v-btn>
</div>
</v-container>
<!-- Content -->
<v-container v-else-if="incident" fluid class="incident-detail pa-6">
<!-- Header -->
<header class="d-flex justify-space-between align-start mb-4 flex-wrap ga-2">
<div>
<div class="d-flex align-center ga-2 mb-1">
<span class="font-mono text-caption text-medium-emphasis">{{ incident.incident_id }}</span>
<v-chip :color="severityInfo(incident.severity).color" size="x-small" variant="tonal">
{{ severityInfo(incident.severity).label }}
</v-chip>
<v-chip :color="statusInfo(incident.status).color" size="x-small" variant="tonal">
{{ statusInfo(incident.status).label }}
</v-chip>
</div>
<h1 class="text-h5 font-weight-medium">{{ incident.summary }}</h1>
</div>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">Назад</v-btn>
</header>
<v-row>
<!-- Main details -->
<v-col cols="12" md="8">
<v-card variant="outlined" class="pa-4 mb-4">
<h2 class="text-h6 mb-3">Детали инцидента</h2>
<div v-if="incident.root_cause" class="mb-3">
<div class="text-caption text-medium-emphasis">Корневая причина</div>
<div>{{ incident.root_cause }}</div>
</div>
<div v-if="incident.postmortem_url" class="mb-3">
<div class="text-caption text-medium-emphasis">Postmortem</div>
<a :href="incident.postmortem_url" target="_blank" rel="noopener">
{{ incident.postmortem_url }}
</a>
</div>
<v-divider class="my-3" />
<v-row dense>
<v-col cols="6">
<div class="text-caption text-medium-emphasis">Начался</div>
<div>{{ formatDate(incident.started_at) }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-medium-emphasis">Обнаружен</div>
<div>{{ formatDate(incident.detected_at) }}</div>
</v-col>
<v-col cols="6" class="mt-2">
<div class="text-caption text-medium-emphasis">Решён</div>
<div>{{ formatDate(incident.resolved_at) }}</div>
</v-col>
<v-col v-if="incident.affected_users_count !== null" cols="6" class="mt-2">
<div class="text-caption text-medium-emphasis">Затронуто пользователей</div>
<div>{{ incident.affected_users_count }}</div>
</v-col>
</v-row>
</v-card>
<!-- Affected tenants -->
<v-card variant="outlined" class="pa-4 mb-4">
<h2 class="text-h6 mb-3">Затронутые тенанты ({{ incident.affected_tenants.length }})</h2>
<div v-if="incident.affected_tenants.length === 0" class="text-medium-emphasis text-body-2">
Нет данных
</div>
<v-list v-else density="compact">
<v-list-item
v-for="t in incident.affected_tenants"
:key="t.id"
:title="t.organization_name"
:subtitle="`ID: ${t.id}`"
/>
</v-list>
</v-card>
</v-col>
<!-- РКН section -->
<v-col cols="12" md="4">
<v-card v-if="incident.type === 'data_breach'" variant="outlined" class="pa-4 mb-4">
<h2 class="text-h6 mb-3">Уведомление РКН (152-ФЗ)</h2>
<div v-if="incident.rkn_notified">
<v-icon color="success" class="mr-1">mdi-check-circle</v-icon>
РКН уведомлён {{ formatDate(incident.rkn_notified_at) }}
</div>
<template v-else>
<div v-if="incident.rkn_deadline_at" class="mb-3">
<div class="text-caption text-medium-emphasis">Дедлайн</div>
<div class="text-error font-weight-medium">{{ formatDate(incident.rkn_deadline_at) }}</div>
</div>
<v-btn
data-testid="rkn-notify-btn"
color="error"
:loading="rknLoading"
block
@click="rknDialog = true"
>
Уведомить РКН
</v-btn>
</template>
<v-alert
v-if="rknError"
type="error"
variant="tonal"
density="compact"
class="mt-3"
data-testid="rkn-error"
>
{{ rknError }}
</v-alert>
</v-card>
<!-- Admin meta -->
<v-card variant="outlined" class="pa-4">
<h2 class="text-h6 mb-3">Служебная информация</h2>
<div v-if="incident.created_by_admin" class="mb-2">
<div class="text-caption text-medium-emphasis">Создал</div>
<div>{{ incident.created_by_admin }}</div>
</div>
<div v-if="incident.closed_by_admin" class="mb-2">
<div class="text-caption text-medium-emphasis">Закрыл</div>
<div>{{ incident.closed_by_admin }}</div>
</div>
<div v-if="incident.created_at">
<div class="text-caption text-medium-emphasis">Создан</div>
<div>{{ formatDate(incident.created_at) }}</div>
</div>
</v-card>
</v-col>
</v-row>
<!-- РКН confirm dialog -->
<v-dialog v-model="rknDialog" max-width="480">
<v-card>
<v-card-title class="text-h6">Подтверждение уведомления РКН</v-card-title>
<v-card-text>
Это юридически значимое действие. После подтверждения будет зафиксировано время уведомления
регулятора (152-ФЗ). Продолжить?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="rknDialog = false">Отмена</v-btn>
<v-btn color="error" :loading="rknLoading" @click="confirmRkn">Подтвердить</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.incident-detail {
max-width: 1200px;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
</style>
@@ -11,6 +11,7 @@
*/
import { ADMIN_INCIDENTS } from '../../composables/mockAdmin';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
@@ -29,6 +30,8 @@ interface IncidentRow {
rkn_deadline_at: string | null;
}
const router = useRouter();
const filterStatus = ref<string>('all');
const statusMap: Record<string, { label: string; color: string }> = {
@@ -210,7 +213,14 @@ function formatDate(iso: string): string {
</div>
<v-list lines="three" class="incidents-list">
<v-list-item v-for="row in filteredRows" :key="row.id" class="incident-row">
<v-list-item
v-for="row in filteredRows"
:key="row.id"
class="incident-row"
:data-testid="`incident-row-${row.id}`"
style="cursor: pointer"
@click="router.push({ name: 'admin-incident-detail', params: { id: row.id } })"
>
<div class="incident-header">
<span class="font-mono text-caption text-medium-emphasis">{{ row.incident_id }}</span>
<v-chip :color="severityInfo(row.severity).color" size="x-small" variant="tonal" class="ml-2">
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminIncidentDetailView from '../../resources/js/views/admin/AdminIncidentDetailView.vue';
import type { ApiAdminIncidentDetail } from '../../resources/js/api/admin';
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
getAdminIncidentDetail: vi.fn(),
notifyIncidentRkn: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
});
function makeDetail(overrides: Partial<ApiAdminIncidentDetail> = {}): ApiAdminIncidentDetail {
return {
id: 7,
incident_id: 'INC-2026-0516-0007',
type: 'data_breach',
severity: 'high',
summary: 'Утечка данных тенантов',
root_cause: 'Неправильная RLS-политика',
postmortem_url: 'https://example.com/postmortem',
started_at: '2026-05-16T10:00:00Z',
detected_at: '2026-05-16T10:30:00Z',
resolved_at: null,
status: 'investigating',
affected_tenants: [
{ id: 1, organization_name: 'Окна Москва ООО' },
{ id: 2, organization_name: 'ИП Петров' },
],
affected_users_count: 42,
notification_sent_at: null,
rkn_notified: false,
rkn_notified_at: null,
rkn_deadline_at: '2026-05-17T10:30:00Z',
created_by_admin: 'admin@liderra.ru',
closed_by_admin: null,
created_at: '2026-05-16T10:35:00Z',
updated_at: '2026-05-16T10:35:00Z',
...overrides,
};
}
const buildRouter = (id: number) => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/incidents', name: 'admin-incidents', component: { template: '<div />' } },
{
path: '/admin/incidents/:id',
name: 'admin-incident-detail',
component: AdminIncidentDetailView,
},
],
});
return router.push({ name: 'admin-incident-detail', params: { id } }).then(() => router);
};
const mountDetail = async (id: number) => {
const router = await buildRouter(id);
await router.isReady();
const wrapper = mount(AdminIncidentDetailView, {
global: {
plugins: [createVuetify(), router],
stubs: { teleport: true },
},
});
await flushPromises();
return wrapper;
};
describe('AdminIncidentDetailView.vue', () => {
it('вызывает getAdminIncidentDetail с id из route и рендерит summary/incident_id/severity', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
const wrapper = await mountDetail(7);
expect(adminApi.getAdminIncidentDetail).toHaveBeenCalledWith(7);
const text = wrapper.text();
expect(text).toContain('INC-2026-0516-0007');
expect(text).toContain('Утечка данных тенантов');
expect(text).toContain('High');
});
it('404 от API → data-testid="incident-not-found"', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockRejectedValue({
response: { status: 404 },
});
const wrapper = await mountDetail(999);
expect(wrapper.find('[data-testid="incident-not-found"]').exists()).toBe(true);
});
it('500 от API → data-testid="incident-fetch-error" + кнопка Повторить', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockRejectedValue({
response: { status: 500, data: { message: 'Backend error' } },
});
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="incident-fetch-error"]').exists()).toBe(true);
// retry button calls loadIncident
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
const retryBtn = wrapper.find('[data-testid="incident-fetch-error"] button, [data-testid="incident-fetch-error"] .v-btn');
expect(retryBtn.exists()).toBe(true);
});
it('data_breach + rkn_notified=false → data-testid="rkn-notify-btn" видна', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail({ type: 'data_breach', rkn_notified: false }));
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(true);
});
it('клик rkn-notify → confirm → вызывает notifyIncidentRkn, карточка обновляется (rkn_notified=true, кнопка исчезает)', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: false }),
);
const notified = makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-16T11:00:00Z' });
vi.mocked(adminApi.notifyIncidentRkn).mockResolvedValue(notified);
const wrapper = await mountDetail(7);
// open dialog via btn
await wrapper.find('[data-testid="rkn-notify-btn"]').trigger('click');
await wrapper.vm.$nextTick();
// call confirmRkn directly via defineExpose
const vm = wrapper.vm as unknown as {
confirmRkn: () => Promise<void>;
incident: ApiAdminIncidentDetail | null;
rknDialog: boolean;
};
await vm.confirmRkn();
await flushPromises();
expect(adminApi.notifyIncidentRkn).toHaveBeenCalledWith(7);
expect(vm.incident?.rkn_notified).toBe(true);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
});
it('type !== data_breach → кнопка РКН-notify отсутствует', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'service_outage', rkn_notified: false }),
);
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
});
it('rkn_notified=true → показывает "РКН уведомлён", кнопки нет', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-17T08:00:00Z' }),
);
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
expect(wrapper.text()).toContain('РКН уведомлён');
});
it('ошибка от notifyIncidentRkn → data-testid="rkn-error" виден', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: false }),
);
vi.mocked(adminApi.notifyIncidentRkn).mockRejectedValue(
new Error('РКН endpoint недоступен'),
);
const wrapper = await mountDetail(7);
const vm = wrapper.vm as unknown as {
confirmRkn: () => Promise<void>;
rknError: string;
};
await vm.confirmRkn();
await flushPromises();
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="rkn-error"]').exists()).toBe(true);
});
});
+23 -10
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
@@ -7,23 +7,24 @@ import AdminIncidentsView from '../../resources/js/views/admin/AdminIncidentsVie
const mountView = async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/admin/incidents', component: AdminIncidentsView }],
routes: [
{ path: '/admin/incidents', name: 'admin-incidents', component: AdminIncidentsView },
{ path: '/admin/incidents/:id', name: 'admin-incident-detail', component: { template: '<div />' } },
],
});
await router.push('/admin/incidents');
await router.isReady();
return mount(AdminIncidentsView, {
global: { plugins: [createVuetify(), router] },
});
return { wrapper: mount(AdminIncidentsView, { global: { plugins: [createVuetify(), router] } }), router };
};
describe('AdminIncidentsView.vue', () => {
it('монтируется и содержит заголовок «Инциденты»', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
expect(wrapper.text()).toContain('Инциденты');
});
it('содержит 3 stats: Открыто / Расследуется / РКН-уведомлений', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
const text = wrapper.text();
expect(text).toContain('Открыто');
expect(text).toContain('Расследуется');
@@ -31,7 +32,7 @@ describe('AdminIncidentsView.vue', () => {
});
it('содержит фильтр-toggle по статусам (5 значений)', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
const text = wrapper.text();
expect(text).toContain('Все');
expect(text).toContain('Открыты');
@@ -40,16 +41,28 @@ describe('AdminIncidentsView.vue', () => {
});
it('показывает PDN-breach с РКН pending chip', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
const text = wrapper.text();
expect(text).toContain('Утечка ПДн');
expect(text).toContain('РКН pending');
});
it('содержит incident_id в формате INC-YYYY-MMDD-NNNN', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
const text = wrapper.text();
expect(text).toContain('INC-2026-0507-0034');
expect(text).toContain('INC-2026-0506-0028');
});
it('клик по строке инцидента вызывает router.push на admin-incident-detail', async () => {
const { wrapper, router } = await mountView();
const pushSpy = vi.spyOn(router, 'push');
// get first row — mock data has id from ADMIN_INCIDENTS[0]
const vm = wrapper.vm as unknown as { rowsState: Array<{ id: number }> };
const firstId = vm.rowsState[0].id;
const row = wrapper.find(`[data-testid="incident-row-${firstId}"]`);
expect(row.exists()).toBe(true);
await row.trigger('click');
expect(pushSpy).toHaveBeenCalledWith({ name: 'admin-incident-detail', params: { id: firstId } });
});
});
+119
View File
@@ -23,6 +23,12 @@ import {
listAdminIncidents,
listSystemSettings,
updateSystemSetting,
listAdminTariffPlans,
updateTenantStatus,
refundTenant,
changeTenantTariff,
getAdminIncidentDetail,
notifyIncidentRkn,
} from '../../resources/js/api/admin';
import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
@@ -353,4 +359,117 @@ describe('api/admin', () => {
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('500 Server Error'));
await expect(listAdminTenants({ status: 'active' })).rejects.toThrow('500 Server Error');
});
// === Sprint 3D G4: billing row-actions ===
it('listAdminTariffPlans() GET /api/admin/billing/tariff-plans + unwraps data.plans', async () => {
const plans = [{ id: 1, name: 'Базовый', price_monthly: '990.00' }];
vi.mocked(apiClient.get).mockResolvedValue({ data: { plans } });
const r = await listAdminTariffPlans();
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/billing/tariff-plans');
expect(r).toHaveLength(1);
expect(r[0].name).toBe('Базовый');
});
it('updateTenantStatus() PATCH /api/admin/billing/tenants/{id}/status + ensureCsrfCookie', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 5, status: 'suspended' } });
const r = await updateTenantStatus(5, 'suspended', 'Нарушение условий');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.patch).toHaveBeenCalledWith('/api/admin/billing/tenants/5/status', {
status: 'suspended',
reason: 'Нарушение условий',
});
expect(r.status).toBe('suspended');
});
it('refundTenant() POST /api/admin/billing/tenants/{id}/refund + ensureCsrfCookie', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { id: 3, balance_rub: '5000.00', transaction_id: 101 },
});
const r = await refundTenant(3, 1000, 'Возврат по заявке');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/billing/tenants/3/refund', {
amount_rub: 1000,
reason: 'Возврат по заявке',
});
expect(r.transaction_id).toBe(101);
});
it('changeTenantTariff() PATCH /api/admin/billing/tenants/{id}/tariff + ensureCsrfCookie', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({
data: { id: 2, tariff_id: 3, tariff_name: 'Команда' },
});
const r = await changeTenantTariff(2, 3, 'Апгрейд по договорённости');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.patch).toHaveBeenCalledWith('/api/admin/billing/tenants/2/tariff', {
tariff_id: 3,
reason: 'Апгрейд по договорённости',
});
expect(r.tariff_name).toBe('Команда');
});
// === Sprint 3D G5/G6: incident detail + РКН-notify ===
it('getAdminIncidentDetail() GET /api/admin/incidents/{id} + unwraps data.incident', async () => {
const incident = {
id: 7,
incident_id: 'INC-2026-0516-0007',
type: 'data_breach',
severity: 'high',
summary: 'Тест',
root_cause: null,
postmortem_url: null,
started_at: '2026-05-16T10:00:00Z',
detected_at: '2026-05-16T10:30:00Z',
resolved_at: null,
status: 'investigating',
affected_tenants: [],
affected_users_count: null,
notification_sent_at: null,
rkn_notified: false,
rkn_notified_at: null,
rkn_deadline_at: null,
created_by_admin: null,
closed_by_admin: null,
created_at: null,
updated_at: null,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: { incident } });
const r = await getAdminIncidentDetail(7);
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/incidents/7');
expect(r.incident_id).toBe('INC-2026-0516-0007');
expect(r.id).toBe(7);
});
it('notifyIncidentRkn() POST /api/admin/incidents/{id}/rkn-notify + ensureCsrfCookie + unwraps data.incident', async () => {
const incident = {
id: 7,
incident_id: 'INC-2026-0516-0007',
type: 'data_breach',
severity: 'high',
summary: 'Тест',
root_cause: null,
postmortem_url: null,
started_at: '2026-05-16T10:00:00Z',
detected_at: '2026-05-16T10:30:00Z',
resolved_at: null,
status: 'investigating',
affected_tenants: [],
affected_users_count: null,
notification_sent_at: '2026-05-16T11:00:00Z',
rkn_notified: true,
rkn_notified_at: '2026-05-16T11:00:00Z',
rkn_deadline_at: '2026-05-17T10:30:00Z',
created_by_admin: null,
closed_by_admin: null,
created_at: null,
updated_at: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: { incident } });
const r = await notifyIncidentRkn(7);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/incidents/7/rkn-notify', {});
expect(r.rkn_notified).toBe(true);
expect(r.rkn_notified_at).toBe('2026-05-16T11:00:00Z');
});
});