feat(admin): G5/G6 frontend — incident detail view + РКН-notify
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user