From ca0c4d9318445280350a1690dd1925c7f28e2e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 16 May 2026 13:50:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20G5/G6=20frontend=20=E2=80=94=20i?= =?UTF-8?q?ncident=20detail=20view=20+=20=D0=A0=D0=9A=D0=9D-notify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/resources/js/api/admin.ts | 45 +++ app/resources/js/router/index.ts | 6 + .../views/admin/AdminIncidentDetailView.vue | 299 ++++++++++++++++++ .../js/views/admin/AdminIncidentsView.vue | 12 +- .../Frontend/AdminIncidentDetailView.spec.ts | 180 +++++++++++ app/tests/Frontend/AdminIncidentsView.spec.ts | 33 +- app/tests/Frontend/admin-api.spec.ts | 119 +++++++ 7 files changed, 683 insertions(+), 11 deletions(-) create mode 100644 app/resources/js/views/admin/AdminIncidentDetailView.vue create mode 100644 app/tests/Frontend/AdminIncidentDetailView.spec.ts diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index aa975881..47121865 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -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 { + const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`); + return data.incident; +} + +export async function notifyIncidentRkn(id: number): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>( + `/api/admin/incidents/${id}/rkn-notify`, + {}, + ); + return data.incident; +} diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index bd780012..e64769ab 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -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', diff --git a/app/resources/js/views/admin/AdminIncidentDetailView.vue b/app/resources/js/views/admin/AdminIncidentDetailView.vue new file mode 100644 index 00000000..3896f91c --- /dev/null +++ b/app/resources/js/views/admin/AdminIncidentDetailView.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/app/resources/js/views/admin/AdminIncidentsView.vue b/app/resources/js/views/admin/AdminIncidentsView.vue index 440bb187..e9697eed 100644 --- a/app/resources/js/views/admin/AdminIncidentsView.vue +++ b/app/resources/js/views/admin/AdminIncidentsView.vue @@ -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('all'); const statusMap: Record = { @@ -210,7 +213,14 @@ function formatDate(iso: string): string { - +
{{ row.incident_id }} diff --git a/app/tests/Frontend/AdminIncidentDetailView.spec.ts b/app/tests/Frontend/AdminIncidentDetailView.spec.ts new file mode 100644 index 00000000..938d2b37 --- /dev/null +++ b/app/tests/Frontend/AdminIncidentDetailView.spec.ts @@ -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(); + return { + ...orig, + getAdminIncidentDetail: vi.fn(), + notifyIncidentRkn: vi.fn(), + }; +}); + +const adminApi = await import('../../resources/js/api/admin'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function makeDetail(overrides: Partial = {}): 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: '
' } }, + { + 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; + 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; + rknError: string; + }; + await vm.confirmRkn(); + await flushPromises(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="rkn-error"]').exists()).toBe(true); + }); +}); diff --git a/app/tests/Frontend/AdminIncidentsView.spec.ts b/app/tests/Frontend/AdminIncidentsView.spec.ts index d15ce204..17476e96 100644 --- a/app/tests/Frontend/AdminIncidentsView.spec.ts +++ b/app/tests/Frontend/AdminIncidentsView.spec.ts @@ -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: '
' } }, + ], }); 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 } }); + }); }); diff --git a/app/tests/Frontend/admin-api.spec.ts b/app/tests/Frontend/admin-api.spec.ts index 43bde9fe..5b3b4c14 100644 --- a/app/tests/Frontend/admin-api.spec.ts +++ b/app/tests/Frontend/admin-api.spec.ts @@ -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'); + }); });